humweb / inertia-table
Inertia.js table component with sorting, filtering, searching, and multi-table support for Laravel
Fund package maintenance!
Requires
- php: ^8.2
- aaronfrancis/fast-paginate: ^2.0
- illuminate/contracts: ^10.0||^11.0||^12.0
- inertiajs/inertia-laravel: ^2
- kirschbaum-development/eloquent-power-joins: ^4.2
- spatie/laravel-package-tools: ^1.19
Requires (Dev)
- larastan/larastan: ^2.9||^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1||^7.10.0
- orchestra/testbench: ^10.0.0||^9.0.0||^8.22.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.3||^2.0
- phpstan/phpstan-deprecation-rules: ^1.1||^2.0
- phpstan/phpstan-phpunit: ^1.3||^2.0
- spatie/laravel-ray: ^1.35
- dev-main
- 3.0
- 2.1
- 2.0
- 1.5
- v1.1
- v1.0
- dev-develop
- dev-dependabot/github_actions/dependabot/fetch-metadata-3.1.0
- dev-dependabot/github_actions/dependabot/fetch-metadata-3.0.0
- dev-dependabot/github_actions/actions/checkout-6.0.2
- dev-dependabot/github_actions/stefanzweifel/git-auto-commit-action-7
- dev-dev
- dev-dist
This package is auto-updated.
Last update: 2026-05-13 17:45:24 UTC
README
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()->meta([ '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 ->meta(['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.