For a domain-centric guide on building tables from schemas, properties, and actions, see Data Tables.
Quick Start
Generate a table class
php artisan make:inertia-table UserTable
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.
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(),
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.
| Class | Automatic Name |
|---|
UserTable | user |
TransactionTable | transaction |
AccountTransactionRequestTable | account-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:
| Option | Type | Default | Description |
|---|
sortable | boolean | false | Enable column sorting |
searchable | boolean | false | Include in table search |
toggleable | boolean | true | Allow the user to hide/show |
visible | boolean | true | Initial visibility |
alignment | ColumnAlignment | Left | Text alignment |
wrap | boolean | false | Allow text wrapping |
truncate | int|null | null | Truncate 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
| Method | Description |
|---|
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
| Method | Description |
|---|
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
| Method | Description |
|---|
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.
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.
Row Links
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;
});
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
| Method | Description |
|---|
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());