Skip to main content
For a domain-centric guide on building tables from schemas, properties, and actions, see Data Tables.

Quick Start

1

Generate a table class

php artisan make:inertia-table UserTable
2

Implement the table

All tables extend CoreTable. Return a query builder from resource(), define columns, and optionally add filters.
use Inly\Core\Tables\CoreTable;
use InertiaUI\Table\Columns\TextColumn;
use InertiaUI\Table\Columns\DateColumn;
use InertiaUI\Table\Columns\ActionColumn;
use InertiaUI\Table\Filters\TextFilter;

class UserTable extends CoreTable
{
    protected ?string $defaultSort = '-created_at';

    public function resource(): Builder|string
    {
        return User::query()->with(['roles']);
    }

    public function columns(): array
    {
        return [
            TextColumn::make('name', __('Name'), sortable: true, searchable: true),
            TextColumn::make('email', __('Email'), sortable: true),
            DateColumn::make('created_at', __('Created')),
            ActionColumn::new(__('Actions')),
        ];
    }

    public function filters(): array
    {
        return [
            TextFilter::make('name', __('Name')),
        ];
    }
}
Set $defaultSort using the column attribute name, prefixed with - for descending (e.g. -created_at). Never call orderBy() in resource() — it breaks column sorting.
3

Pass the table from your controller

public function index()
{
    return inertia('users/user-index', [
        'userTable' => UserTable::make(),
    ]);
}
For expensive queries, defer the table so it doesn’t block the initial page load:
'userTable' => UserTable::defer(),
4

Render on the frontend

Import InertiaTable from the Core component — not directly from InertiaUI.
import {
  InertiaTable,
  InertiaTableResource,
} from '@/core/components/inertia-table';

interface Props {
  userTable: InertiaTableResource;
}

export default function UserIndex({ userTable }: Props) {
  return <InertiaTable resource={userTable} />;
}

CoreTable

CoreTable extends InertiaUI\Table\Table and is the only base class you should use. It adds automatic table naming, CSV/XLSX exports, and row styling.

Automatic Naming

Each table is named from its class name, preventing query string conflicts when multiple tables share a page.
ClassAutomatic Name
UserTableuser
TransactionTabletransaction
AccountTransactionRequestTableaccount-transaction-request
Override the name when needed:
// Override getDefaultName()
protected function getDefaultName(): string
{
    return 'custom-users';
}

// Or use as() after instantiation
UserTable::make()->as('custom-users');

Exports

CoreTable automatically provides CSV and XLSX exports via CoreTableExport. Exports are queued and send an email notification when ready. The export filename prefix comes from getExportTitle() (if defined), then from getObjectCollectionTitle() on the model, then falls back to null. Override exports() to customize:
use Inly\Core\Tables\Exports\CoreTableExport;
use Maatwebsite\Excel\Excel;

public function exports(): array
{
    return [
        CoreTableExport::make(fileNamePrefix: __('Users'), label: __('Download CSV'),   type: Excel::CSV),
        CoreTableExport::make(fileNamePrefix: __('Users'), label: __('Download Excel'), type: Excel::XLSX),
    ];
}

getFilterValue()

Returns the active value of a filter by its attribute name. Returns null if the filter is inactive; throws HTTP 400 if the filter is disabled.
$campaignIds = $this->getFilterValue('accounts.campaign_id');

if ($campaignIds !== null) {
    $query->whereIn('campaign_id', $campaignIds);
}

Column Types

All columns share these common options:
OptionTypeDefaultDescription
sortablebooleanfalseEnable column sorting
searchablebooleanfalseInclude in table search
toggleablebooleantrueAllow the user to hide/show
visiblebooleantrueInitial visibility
alignmentColumnAlignmentLeftText alignment
wrapbooleanfalseAllow text wrapping
truncateint|nullnullTruncate after N lines

TextColumn

TextColumn::make('name', __('Full Name'), sortable: true, searchable: true)
    ->mapAs(fn(string $name) => strtoupper($name));

NumericColumn

NumericColumn::make('price', __('Price'))->alignment(ColumnAlignment::Right);

DateColumn / DateTimeColumn

DateColumn::make('created_at', __('Created'))->format('d/m/Y')->translate();
DateTimeColumn::make('updated_at', __('Last Updated'))->format('d/m/Y H:i:s')->translate();

BooleanColumn

BooleanColumn::make('is_active', __('Active'))
    ->trueLabel(__('Yes'))->falseLabel(__('No'))
    ->trueIcon('CheckCircle')->falseIcon('XCircle');

ImageColumn

ImageColumn::make('avatar_url', __('Avatar'))->rounded()->size('large');

ActionColumn

Add one ActionColumn per table to render row action buttons.
ActionColumn::new(__('Actions'));

Core Columns

Core columns extend the base types with domain-aware rendering and per-row dynamic styling.

CoreColumn Base

All Core columns support rowCellClass() for per-row dynamic cell styling:
TextColumn::make('status', __('Status'))
    ->rowCellClass(fn ($row) => match($row->status) {
        'active'   => 'bg-green-50 dark:bg-green-950',
        'pending'  => 'bg-yellow-50 dark:bg-yellow-950',
        default    => '',
    });

CoreBadgeColumn

Displays badge components with automatic enum support or manual configuration.
// Automatic — works with enums implementing SerializeAsBadge
CoreBadgeColumn::make('status', __('Status'))->sortable();

// Manual
CoreBadgeColumn::make('priority', __('Priority'))
    ->label(fn($p) => match($p) { 1 => 'Low', 2 => 'Medium', 3 => 'High' })
    ->variant(['low' => 'secondary', 'medium' => 'warning', 'high' => 'destructive'])
    ->icon(['high' => 'AlertCircle']);

ObjectColumn

Displays one or more domain objects from a named attribute or relationship. Automatically reads title, subtitle, avatar, and URL from models implementing ObjectContract.
// Direct attribute
ObjectColumn::make('company', __('Company'))->sortable();

// Nested relationship
ObjectColumn::make('user.company', __('Company'));

// Custom resolver
ObjectColumn::make('primary_contact', __('Contact'))
    ->asModel(fn($account) => $account->primaryContact);

// Collections with a display limit
ObjectColumn::make('teams', __('Teams'))->limit(3);

// Fallback when empty
ObjectColumn::make('assignee', __('Assignee'))
    ->fallback(fn($row) => ObjectData::from(['title' => __('Unassigned'), 'icon' => 'UserX']));

Overrides

All ObjectData and AvatarData fields can be overridden per-column. Values may be plain scalars or Closures receiving the source model.
ObjectColumn::make('school', __('School'))
    ->title(fn($model) => $model->school->full_name)
    ->subtitle(fn($model) => $model->school->city)
    ->badgeIcon('School')
    ->badgeVariant(CoreVariant::SUCCESS)
    ->badgeTooltip(fn($model) => $model->school->district);
ObjectData overrides
MethodDescription
title(string|Closure|null)Override the object title
subtitle(string|Closure|null)Override the object subtitle
objectUrl(string|Closure|null)Override the link URL
AvatarData overrides
MethodDescription
avatar(string|Closure|null)Override the avatar image URL
avatarFallback(string|Closure|null)Override initials fallback text
avatarFallbackHue(int|Closure|null)Override the fallback colour hue
avatarIcon(string|Closure|null)Override the icon name
badgeIcon(string|Closure|null)Add or override the avatar badge icon
badgeVariant(CoreVariant|Closure|null)Set the badge variant
badgeTooltip(string|Closure|null)Set the badge tooltip text
Other options
MethodDescription
asModel(Closure)Custom resolver returning the object(s) to display
fallback(Closure)Called when empty; must return ObjectData or ObjectData[]
size(?string)Override the avatar display size
limit(?int)Limit how many objects are rendered

Escape hatch: mapAs

When the named override methods aren’t enough, use mapAs to build the ObjectData entirely yourself. The closure receives the source model and must return an ObjectData instance.
ObjectColumn::make('name', __('Name'))
    ->mapAs(fn($row) => new ObjectData(...));
Prefer the named override methods over mapAs whenever possible — they are more concise and only override what you specify.

ObjectReferencesColumn

Displays the cross-references a row model declares via getObjectReferences(). The attribute name is used only as a column key.
ObjectReferencesColumn::make('references', __('References'));
The row model must implement ObjectContract and define getObjectReferences():
public function getObjectReferences(): array
{
    return array_filter([$this->account, $this->campaign]);
}

InlineEditColumn

Enables inline editing of a cell value directly in the table row.
InlineEditColumn::make('name', __('Name'))
    ->placeholder(__('Enter name'))
    ->method('patch');

// With CoreAction integration
InlineEditColumn::make('title', __('Title'))
    ->coreAction(UpdateProductAction::class, fn($product) => ['id' => $product->id])
    ->searchable()
    ->sortable();
See Actions — Inline Edit Integration for full details.

Filters

TextFilter

TextFilter::make('name', __('Name'));

SetFilter

SetFilter::make('status', __('Status'))->options([
    'active'   => __('Active'),
    'inactive' => __('Inactive'),
]);

// From a relationship
SetFilter::make('category', __('Category'))->pluckOptionsFromRelation('name');
When building options from a filtered collection (e.g. after pluck()->filter()), always call ->values() at the end. Without it, the filter UI renders array indices instead of option labels.

BooleanFilter

BooleanFilter::make('is_active', __('Active'));

DateFilter

DateFilter::make('created_at', __('Created'))->nullable();

// Date range
DateFilter::make('created_at', __('Created'))->range()->nullable();

RangeFilter

RangeFilter::make('price', __('Price Range'))->min(0)->max(1000)->step(10);

CoreBadgeFilter

Filters on badge-serializable enum values with automatic label and colour rendering:
CoreBadgeFilter::make('status', __('Status'));

Actions

The recommended approach is coreActions() with standard CoreAction classes. Actions carry their own authorization, labels, icons, and confirmation dialogs, and work in table rows, bulk bars, page headers, and dropdowns.

coreActions()

use Inly\Core\Actions\LinkCoreAction;
use Inly\Core\Actions\ModalLinkCoreAction;

class UserTable extends CoreTable
{
    public function coreActions(): array
    {
        return [
            LinkCoreAction::make(__('View'))
                ->href(fn(User $user) => route('users.show', $user->id))
                ->icon('Eye'),

            ModalLinkCoreAction::make(__('Edit'))
                ->url(fn(User $user) => route('users.edit', $user->id))
                ->icon('Pencil'),

            DeactivateUsersAction::make(),

            DeleteUserAction::make()->asBulkAction(),
        ];
    }
}
  • Row actions automatically receive the row id when triggered.
  • Bulk actions receive an ids array, or ['*'] when the user selects all.
Use ModalLinkCoreAction to open a server-side modal from a row. It supports sheet(), modalClassName(), and localModal(). Use LinkCoreAction to navigate to a URL — add ->external() to open in a new tab. See Table Actions for building actions that operate on single or bulk rows.

Legacy Actions

Prefer coreActions() for all new work. The actions() / CoreTableAction / CoreModalTableAction system is kept for backwards compatibility.
Use actions() only when you need raw InertiaUI Table behaviour not available through CoreAction:
use InertiaUI\Table\Action;

Action::make(__('Delete'), handle: fn(User $user) => $user->delete())
    ->asDangerButton()
    ->icon('Trash')
    ->confirm(title: __('Delete user'), message: __('Are you sure?'), confirmButton: __('Delete')),

Advanced Features

Cell Overrides

Override the rendering of individual columns on the frontend:
<InertiaTable
  resource={productTable}
  cell={{
    name: ({ value }) => <strong>{value}</strong>,
    status: ({ value }) => (
      <Badge variant={value === 'active' ? 'success' : 'secondary'}>
        {value}
      </Badge>
    ),
  }}
/>
The render function receives item, column, value, image, table, and actions.

DTO Transformation

Transform row models into DTOs before they reach the frontend:
public function transformModel(Model $model, array $data): array
{
    return [...$data, 'data' => ProductData::from($model)->toArray()];
}
Access the DTO via item.data in cell overrides. Make entire rows navigate to a detail page:
TextColumn::make('email', __('Email'))->route('users.show', $user);

Sticky Columns

Stick a column to the left edge during horizontal scrolling:
TextColumn::make('name', __('Name'))->stickable(),

Empty State

protected array $emptyState = [
    'title'       => 'No users found',
    'description' => 'Get started by creating your first user.',
    'icon'        => 'Users',
    'actions'     => [
        ['label' => 'Create user', 'url' => '/users/create', 'style' => 'primary', 'icon' => 'Plus'],
    ],
];

Icon Resolver

Set up a global resolver once in your application entry point:
import { setIconResolver } from '@/core/components/inertia-table';
import { CheckCircle, XCircle } from 'lucide-react';

setIconResolver((iconName: string) => {
  const icons = { CheckCircle, XCircle };
  return icons[iconName] ?? null;
});

Pagination

Inertia Table handles pagination automatically. The global default is 30 records per page.
protected array $perPageOptions = [10, 25, 50, 100]; // User-selectable
protected array $perPageOptions = [30];               // Fixed, no selector

Scoped Tables

When a table is constrained to a parent model — for example, all users belonging to a specific school — pass the parent as a constructor parameter and mark it with #[Remember]:
use InertiaUI\Table\Remember;

class SchoolUserTable extends CoreTable
{
    public function __construct(
        #[Remember] public ?School $school = null,
    ) {}

    public function resource(): Builder|string
    {
        return SchoolUser::query()
            ->when($this->school, fn ($q) => $q->where('school_id', $this->school->id));
    }
}
#[Remember] is required whenever a table has constructor parameters that scope its query. Exports and bulk actions are separate HTTP requests — the table must be reconstructed from scratch. Without #[Remember], those requests instantiate the table without arguments and the scope is silently lost, causing exports and bulk actions to run against the full dataset.
The attribute encrypts and embeds the constructor argument values into the table’s state token on every response. When an export or bulk action request arrives, the table is recreated via fromEncryptedState(), restoring the original arguments.
// Passing the scoped table from a controller
SchoolUserTable::make(school: $school)
SchoolUserTable::defer(school: $school)

Multiple Tables

When multiple tables appear on the same page, CoreTable automatically assigns each a unique name, keeping sorting, filtering, and pagination independent.
return inertia('accounts/account-show', [
    'transactionTable'        => new TransactionTable($account),
    'transactionRequestTable' => new AccountTransactionRequestTable($account),
]);
export default function AccountShow({
  transactionTable,
  transactionRequestTable,
}: Props) {
  return (
    <div className="space-y-8">
      <InertiaTable resource={transactionTable} />
      <InertiaTable resource={transactionRequestTable} />
    </div>
  );
}

Traits

WithStats

Adds aggregate statistics to your table. Stats are available as table.stats on the frontend.
use Inly\Core\Tables\Traits\WithStats;

class ProductTable extends CoreTable
{
    use WithStats;

    protected function stats(): array
    {
        return [
            'total'        => ['label' => __('Total'),        'value' => $this->queryWithRequestApplied()->count()],
            'out_of_stock' => ['label' => __('Out of stock'), 'value' => $this->queryWithRequestApplied()->where('stock', 0)->count()],
        ];
    }
}
stats() runs on every table request. Use queryWithRequestApplied() for filter-aware counts and cache expensive aggregates.

WithRowStyles

Provides row-level HTML data attributes for CSS-based row styling. Applied automatically by CoreTable with a trashed attribute. Override dataAttributesForModel() to add custom attributes:
public function dataAttributesForModel(Model $model, array $data): array
{
    return [
        'trashed' => $model->trashed(),
        'status'  => $model->status,
        'overdue' => $model->due_date?->isPast(),
    ];
}
tr[data-trashed='true'] {
  @apply opacity-60 bg-gray-50;
}

SearchUsingScout

Replaces default database LIKE search with Laravel Scout:
use Inly\Core\Tables\Traits\SearchUsingScout;

class ProductTable extends CoreTable
{
    use SearchUsingScout;

    protected ?string $resource = Product::class;
}
Custom Scout logic:
protected function applyScoutSearch(QueryBuilder $queryBuilder): void
{
    $queryBuilder->searchUsing(function (Builder $query, string $search) {
        if ($search) {
            $ids = Product::search($search)->where('published', true)->get()->pluck('id');
            $query->whereIn('id', $ids);
        }
    });
}

WithTableQuery Trait

WithTableQuery lets a Form Request operate on one or more table rows, supporting both single item updates (via a route parameter) and bulk updates (via an ids array) from the same endpoint.

Basic Usage

use Inly\Core\Http\Requests\Traits\WithTableQuery;

class UpdateUserStatusRequest extends FormRequest
{
    use WithTableQuery;

    protected function getTable(): Table|string
    {
        return UserTable::class;
    }

    public function rules(): array
    {
        return [
            'status' => ['required', 'string', Rule::in(['active', 'inactive'])],
        ];
    }
}
In your controller, use processModels() — it handles both single and bulk cases automatically:
public function update(UpdateUserStatusRequest $request)
{
    $request->processModels(fn($user) => $user->update($request->validated()));

    $count   = $request->getCount();
    $message = $count === 1
        ? __('User updated successfully')
        : __(':count users updated', ['count' => $count]);

    return back()->withSuccess($message);
}

Helper Methods

MethodDescription
getTableQuery()Query builder scoped to target IDs (supports * for select-all)
getModels()All target models as a collection
getCount()Count of affected records (cached)
processModels()Execute a callback on each model, tracks count
isBulkOperation()True if the request has an ids array
hasSingleId()True if the request has a single id
getRouteName()Route name of the referring page
getRouteParameter()Route parameter from the referring page
validated() automatically excludes the ids field from the returned data.

Multiple Entry Points

When the same resource can be updated from different pages, resolve the correct table using getRouteName():
protected function getTable(): Table|string
{
    return match ($this->getRouteName()) {
        'deposits.index'    => DepositTable::class,
        'withdrawals.index' => WithdrawalTable::class,
        'accounts.show'     => new AccountTransactionRequestTable(
            Account::findOrFail($this->getRouteParameter('account'))
        ),
        default => throw new \InvalidArgumentException("Unknown route: {$this->getRouteName()}"),
    };
}

Select All

When the frontend sends { ids: ['*'] }, getTableQuery() automatically applies the active table filters from the referring URL:
$request->getTableQuery()->lazy()->each->update($request->validated());