humweb/inertia-table
Inertia Table
Server-driven data tables for Laravel + Inertia.js + Vue 3. Define your columns, filters, sorts, and search on the backend — the frontend renders it all automatically with per-table partial reloads.
Installation
composer require humweb/inertia-table
Publish the config (optional):
php artisan vendor:publish --tag="inertia-table-config"
Quick Start
1. Define a Resource
A Resource declares your table's columns, filters, model, and query behavior:
use Humweb\Table\Resource;
use Humweb\Table\Fields\{FieldCollection, ID, Text, Badge};
use Humweb\Table\Filters\{FilterCollection, SelectFilter, TextFilter};
class UserResource extends Resource
{
protected string $model = User::class;
public string|Sort $defaultSort = 'name';
protected array $with = ['team'];
public function fields(): FieldCollection
{
return FieldCollection::make([
ID::make('ID')->sortable(),
Text::make('Name')->sortable()->searchable(),
Text::make('Email')->sortable()->searchable(),
Badge::make('Status')->sortable()->withMeta([
'map' => [
'active' => ['label' => 'Active', 'class' => 'badge-green'],
'inactive' => ['label' => 'Inactive', 'class' => 'badge-gray'],
],
]),
]);
}
public function filters(): FilterCollection
{
return FilterCollection::make([
SelectFilter::make('status', 'Status', [
'active' => 'Active',
'inactive' => 'Inactive',
]),
TextFilter::make('name', 'Name'),
]);
}
}
2. Use in a Controller
Single table
use Inertia\Inertia;
class UserController extends Controller
{
public function index(Request $request)
{
return Inertia::render('Users/Index')
->table(fn (InertiaTable $table) =>
UserResource::make($request)->toResponse($table)
);
}
}
Multiple tables on one page
public function index(Request $request)
{
return Inertia::render('Staff/Teams/Show', [
'team' => $team,
])
->table('members', fn (InertiaTable $table) =>
MemberResource::make($request)->toResponse($table)
)
->table('invitations', fn (InertiaTable $table) =>
InvitationResource::make($request)->toResponse($table)
);
}
Each table is a lazy closure, so when the frontend does a partial reload targeting one table (e.g. only: ['tables.members']), only that table's query runs — the other stays untouched.
3. Frontend (Vue 3)
Single table
<script setup lang="ts">
import { DataTable } from '@/components/Table/v2'
</script>
<template>
<DataTable />
</template>
Multiple tables
<script setup lang="ts">
import { DataTable } from '@/components/Table/v2'
</script>
<template>
<DataTable table-key="members" />
<DataTable table-key="invitations" />
</template>
Using the composable directly
<script setup lang="ts">
import { useTable } from '@/components/Table/v2'
const members = useTable('members')
const invitations = useTable('invitations')
</script>
<template>
<input
:value="members.search.value.global?.value ?? ''"
@input="members.updateGlobalSearch(($event.target as HTMLInputElement).value)"
/>
<div v-for="record in members.records.value" :key="record.id">
{{ record.name }}
</div>
</template>
Backend API
Resource
Extend Humweb\Table\Resource to define a table. Required methods:
| Method | Returns | Purpose |
|---|---|---|
fields() |
FieldCollection |
Column definitions |
filters() |
FilterCollection |
Filter definitions (optional, defaults to empty) |
Key properties:
| Property | Type | Default | Purpose |
|---|---|---|---|
$model |
string |
— | Eloquent model class |
$defaultSort |
string|Sort |
'id' |
Default sort column or Sort instance |
$with |
array |
[] |
Eager-loaded relationships |
$primaryKey |
string |
'id' |
Record identifier |
$parameters |
array |
[] |
Route parameters passed to custom filters |
Custom parameter filters
Define filter{StudlyKey}($value) methods on your resource. Parameters set via addParameter() auto-dispatch to these methods:
$resource->addParameter('team_id', $team->id);
// In resource:
public function filterTeamId($value): void
{
$this->query->where('team_id', $value);
}
Custom global search
Override globalFilter() to replace the default OR-across-searchable-fields behavior:
public function globalFilter($query, $value): void
{
$query->where(function ($q) use ($value) {
$q->where('name', 'ilike', "%{$value}%")
->orWhere('email', 'ilike', "%{$value}%");
});
}
Runtime transforms
$resource->runtimeTransform(function ($record) {
$record['full_name'] = $record['first_name'] . ' ' . $record['last_name'];
return $record;
});
Fields
All fields extend Humweb\Table\Fields\Field and use the make() static constructor.
Available field types
| Class | Component | Purpose |
|---|---|---|
ID |
id-field |
Primary key |
Text |
text-field |
Text column |
Textarea |
textarea-field |
Long text |
Number |
number-field |
Numeric |
Date |
date-field |
Date/datetime |
Boolean |
boolean-field |
True/false badge |
Badge |
badge-field |
Status badge with map |
Currency |
currency-field |
Formatted currency |
Percent |
percent-field |
Progress bar |
Image |
image-field |
Image thumbnail |
Avatar |
avatar-field |
Round avatar |
Link |
link-field |
Clickable link |
Relation |
relation-field |
Related model link |
Computed |
computed-field |
Server-computed value |
Actions |
action-field |
Row action buttons |
Field modifiers
Text::make('Name')
->sortable() // Enable server-side sorting (BasicSort)
->sortable(new PowerJoinSort('team', 'name')) // Sort via relation
->sortable(new AggregateSort('posts', 'count')) // Sort by withCount
->sortableOnClient() // Client-side sort (no server round-trip)
->sortField('name_lower') // Sort on a different column than display
->searchable() // Include in column search
->visible(false) // Hidden by default
->visibility(true) // Allow toggling visibility
->nullable() // Mark as nullable
->withMeta(['tooltip' => 'Full name']) // Arbitrary metadata sent to frontend
Filters
All filters extend Humweb\Table\Filters\Filter.
| Class | Component | Purpose |
|---|---|---|
TextFilter |
text-filter |
Free text input |
SelectFilter |
select-filter |
Dropdown select |
BooleanFilter |
boolean-filter |
Yes/No/Any |
DateRangeFilter |
date-range-filter |
From/to date picker |
NumberRangeFilter |
number-range-filter |
Min/max number |
EnumFilter |
enum-filter |
Enum value select |
ScopeFilter |
scope-filter |
Named query scope |
RelationshipFilter |
relationship-filter |
Filter by related model |
EmptyNotEmptyFilter |
empty-filter |
Null/empty check |
TrashedFilter |
select-filter |
Soft delete filter |
Filter modifiers
TextFilter::make('name', 'Name')
->exact() // Exact match instead of LIKE
->startsWith() // LIKE 'value%'
->endsWith() // LIKE '%value'
->fullSearch() // LIKE '%value%' (default)
->relation('team', 'name') // Filter within a relationship
->rules('string|max:100') // Validation rules
Sort Strategies
Sorts implement Humweb\Table\Sorts\Sort and are passed to ->sortable():
| Class | Purpose | Example |
|---|---|---|
BasicSort |
Simple ORDER BY (default). Delegates to Power Joins for dotted paths. |
->sortable() |
PowerJoinSort |
Sort by a column on a related model via Power Joins. | ->sortable(new PowerJoinSort('author', 'name')) |
AggregateSort |
Sort by withCount, withSum, withAvg, etc. |
->sortable(new AggregateSort('orders', 'sum', 'total')) |
SubquerySort |
Sort by an arbitrary subquery (escape hatch). | ->sortable(new SubquerySort(fn ($q) => ...)) |
CallbackSort |
Sort via a custom callback. | ->sortable(new CallbackSort(fn ($q, $desc, $prop) => ...)) |
NullsLastSort |
Sort with NULLs always at the bottom. | ->sortable(new NullsLastSort()) |
Collection sorts (client-side on server)
For sorts that require fetching all records and sorting in PHP (e.g. computed values):
| Class | Purpose |
|---|---|
BasicCollectionSort |
Sort a collection with auto type detection |
CallbackCollectionSort |
Custom collection sort callback |
Text::make('Score')
->sortable(new BasicCollectionSort(SortType::Integer), SortMode::Collection)
Query Pipeline
The Resource builds queries through a QueryPipeline of discrete QueryStage objects. The default pipeline runs these stages in order:
ApplyEagerLoads—$withrelationshipsApplyDefaultSort— fallback sort when no?sort=paramApplySorts— user-requested sort from?sort=paramApplyGlobalSearch—?search[global]=(OR across searchable fields)ApplyCustomFilters— parameter-basedfilter*()methodsApplySearch— per-column?search[name]=ApplyFilters—FilterCollectionapplication from?filters[status]=
Customizing the pipeline
Override pipeline() in your resource to add, replace, or reorder stages:
protected function pipeline(QueryPipeline $pipeline): QueryPipeline
{
// Add a custom stage before sorting
$pipeline->before(ApplySorts::class, new MyCustomStage());
// Replace the default global search
$pipeline->replace(ApplyGlobalSearch::class, new MyGlobalSearch());
// Add a stage after filters
$pipeline->after(ApplyFilters::class, new ApplyTenantScope($this->tenantId));
return $pipeline;
}
Creating custom stages
Implement QueryStage:
use Humweb\Table\Pipeline\QueryStage;
use Humweb\Table\TableRequest;
use Illuminate\Database\Eloquent\Builder;
class ApplyTenantScope implements QueryStage
{
public function __construct(private int $tenantId) {}
public function handle(Builder $query, TableRequest $request, Closure $next): Builder
{
$query->where('tenant_id', $this->tenantId);
return $next($query);
}
}
TableRequest
TableRequest wraps the HTTP request with table-key awareness. For the default key, params are unprefixed (?sort=name). For named keys, params are prefixed (?members.sort=name).
$tableRequest = new TableRequest($request, 'members');
$tableRequest->getSortParam(); // reads ?members.sort=
$tableRequest->getSearchParams(); // reads ?members.search[...]=
$tableRequest->getFilterParams(); // reads ?members.filters[...]=
$tableRequest->getPage(); // reads ?members.page=
$tableRequest->getPerPage(); // reads ?members.perPage=
Multi-Table Response Macro
The ->table() macro on Inertia\Response supports two signatures:
// Single table (key = 'default', prop = 'table')
->table(fn (InertiaTable $table) => ...)
// Named table (prop = 'tables.{key}')
->table('members', fn (InertiaTable $table) => ...)
->table('invitations', fn (InertiaTable $table) => ...)
Each table is registered as a lazy closure. On the initial page visit both resolve. On partial reloads (e.g. sorting/filtering), Inertia's only parameter ensures only the targeted table re-evaluates.
Frontend API
All frontend code lives in resources/js/components/Table/v2/.
useTable(key?, options?)
The core composable. Call it with a table key to bind to a specific table's data from the Inertia page props.
import { useTable } from '@/components/Table/v2'
const table = useTable('members', {
debounceMs: 300,
preserveScroll: true,
additionalOnly: ['team'],
})
Options
| Option | Type | Default | Purpose |
|---|---|---|---|
debounceMs |
number |
250 |
Debounce delay for search/filter changes |
preserveScroll |
boolean |
true |
Preserve scroll position on reload |
additionalOnly |
string[] |
[] |
Extra Inertia only keys to include in partial reloads |
Return value
| Property | Type | Description |
|---|---|---|
key |
string |
Table identifier |
sort |
Ref<string | null> |
Current sort (e.g. 'name' or '-name') |
page |
Ref<number> |
Current page |
perPage |
Ref<number> |
Items per page |
columns |
ComputedRef<TableColumn[]> |
All column definitions |
visibleColumns |
ComputedRef<TableColumn[]> |
Only visible columns |
filters |
ComputedRef<TableFilterItem[]> |
Filter definitions with values |
search |
ComputedRef<TableSearchMap> |
Search field state |
hasGlobalSearch |
ComputedRef<boolean> |
Whether global search is available |
records |
ComputedRef<T[]> |
Current records (client-sorted if applicable) |
pagination |
ComputedRef<PaginationData> |
Pagination metadata |
isLoading |
Ref<boolean> |
Request in-flight indicator |
Methods
| Method | Signature | Description |
|---|---|---|
handleSort |
(attribute: string) => void |
Cycle sort: null -> asc -> desc -> null |
updateFilter |
(key: string | number, value: unknown) => void |
Set a filter value |
updateSearch |
(key: string, value: unknown) => void |
Set a column search value |
updateGlobalSearch |
(value: unknown) => void |
Set global search value |
enableSearch |
(key: string) => void |
Enable a column search field |
removeSearch |
(key: string) => void |
Disable and clear a search field |
setPage |
(page: number) => void |
Navigate to page |
setPerPage |
(perPage: number) => void |
Change per-page (resets to page 1) |
toggleColumnVisibility |
(attribute: string, visible: boolean) => void |
Show/hide a column |
refresh |
() => void |
Force reload this table |
<DataTable> Component
The main component. Initializes useTable and provides it to child components via provide('table').
<DataTable
table-key="members"
:enable-row-selection="true"
selection-key="id"
caption="Team members"
aria-label="Team members table"
>
<!-- Override any section with slots -->
<template #toolbar="{ table }">
<MyCustomToolbar :table="table" />
</template>
<template #cell:status="{ record, field }">
<MyStatusBadge :status="record.status" />
</template>
<template #pagination="{ table }">
<MyPagination :pagination="table.pagination.value" />
</template>
</DataTable>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tableKey |
string |
'default' |
Table key matching the backend |
enableRowSelection |
boolean |
false |
Show row checkboxes |
selectionKey |
string |
'id' |
Record property for selection identity |
hideToolbar |
boolean |
false |
Hide the toolbar |
caption |
string |
'' |
Accessible table caption |
ariaLabel |
string |
'' |
Accessible table label |
options |
UseTableOptions |
{} |
Options forwarded to useTable |
Slots
| Slot | Scope | Description |
|---|---|---|
toolbar |
{ table } |
Replace the entire toolbar |
table |
{ table, records } |
Replace the entire table element |
head |
{ columns, sortHandler, sort } |
Replace the <thead> |
body |
{ records, columns } |
Replace the <tbody> |
cell:{attribute} |
{ record, field } |
Override a specific column cell |
pagination |
{ table } |
Replace pagination |
Sub-components
All sub-components inject useTable via inject('table') and can be used standalone:
| Component | Purpose |
|---|---|
TableToolbar |
Search, filters, column visibility |
TableHeader / TableHeaderCell |
Sortable column headers |
TableBody / TableBodyCell |
Record rows with field rendering |
TablePagination |
Page navigation and per-page select |
FieldRenderer |
Resolves field component by component type |
FilterRenderer |
Resolves filter component by component type |
GlobalSearch |
Search input for global search |
ColumnSearch |
Active column search fields |
ColumnSearchDropdown |
Dropdown to enable column searches |
Imports
// Components
import { DataTable, TableHeader, TableBody, TablePagination } from '@/components/Table/v2'
// Composable
import { useTable } from '@/components/Table/v2'
// Types
import type { TableColumn, UseTableReturn, PaginationData } from '@/components/Table/v2'
Configuration
// config/inertia-table.php
return [
'pagination' => [
'max_per_page' => 100,
'default_per_page' => 15,
],
];
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Credits
License
The MIT License (MIT). Please see License File for more information.
Related Packages
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| 3.0 | ^8.2 | ^10.0|| | MIT | |
| 2.1 | ^8.1 | ^10.0 | MIT | |
| 2.0 | ^8.1 | ^10.0 | MIT | |
| 1.5 | ^8.1 | ^10.0 | MIT | |
| v1.1 | ^8.0 | ^10.0 | MIT | |
| v1.0 | ^8.0 | ^10.0 | MIT |