eduardoapatat / laravel-inertia-datatable
Server-side DataTables for Laravel + Inertia + React: pagination, sorting, filters, faceted counts and global search resolved on the server, with a shadcn/TanStack Table UI.
Package info
github.com/eduardoapatat/laravel-inertia-datatable
Language:TypeScript
pkg:composer/eduardoapatat/laravel-inertia-datatable
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/process: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Reusable DataTable for Laravel + Inertia + React, built on shadcn/ui and TanStack Table, with pagination, sorting, filters, global search and column visibility resolved on the server.
Version 0.9: backend and React UI. The Vue version is in progress. Changes in
CHANGELOG.md.
What this repo contains
| Part | Where | How to install it |
|---|---|---|
| Composer package (backend) | src/, config/ |
composer require eduardoapatat/laravel-inertia-datatable |
| shadcn registry (React) | registry.json, registry/, r/ |
php artisan inertia-datatable:ui or pnpm dlx shadcn@latest add <url> |
| Claude Code skill | skill/ |
pnpm dlx shadcn@latest add <url> |
The contract between frontend and backend is in docs/contract.md.
Backend installation
composer require eduardoapatat/laravel-inertia-datatable
php artisan vendor:publish --tag=inertia-datatable-config # optional
Requires PHP 8.3+ and Laravel 12 or 13. It can also be installed straight from GitHub (vcs repository):
"repositories": [ { "type": "vcs", "url": "https://github.com/eduardoapatat/laravel-inertia-datatable" } ]
Or from a local folder (path), useful for development:
"repositories": [ { "type": "path", "url": "../laravel-inertia-datatable", "options": { "symlink": true } } ]
With these repositories: composer require eduardoapatat/laravel-inertia-datatable:@dev.
Creating a table
php artisan make:datatable ProductsTable # only the class (guessed model: App\Models\Product) php artisan make:datatable ProductsTable --react # + component components/data-tables/products-table.tsx php artisan make:datatable ProductsTable --page # + component + page pages/products/index.tsx php artisan make:datatable ProductsTable --react --install-ui # also installs the generic UI if missing php artisan make:datatable OrdersTable --model=Domain/Sales/Order --empty
It proposes the columns from the model casts and the table schema, only with what is needed to show them: text, numbers (decimals(N) for decimal:N casts), booleans, dates, enums (Column::option()->options(Enum::class)), only from the model's own columns: foreign keys (category_id) are left out, and relation columns (Column::option('category')->from('category.name')) are added by hand. The global search goes on a name column, else on a title one; sorting, filters and hiding are yours to add. It skips the primary key, $hidden attributes and sensitive columns (password, remember_token…).
React has two levels: the generic components (components/data-table/, hook and libs) are installed once per project from the registry, and each table has its own component (components/data-tables/<resource>-table.tsx) reusable in any page. The PHP class decides which columns are shown and in which order (also the order of the filters): the columns the component does not declare are rendered with a default cell for their type, and the component only declares the columns it wants to customize (for example, a cell with a badge) or the ones that only exist on the client (actions or selection, at the end unless meta: { position: 'start' }).
The layout (search, filters, Reset, column menu and pagination) is also changed in each table's component, without touching the shared components: DataTableToolbar is made of exported pieces (DataTableSearch, DataTableFilters, DataTableFilter, DataTableResetButton, DataTableViewOptions) that can be placed in any order, and with <DataTable pagination={false}> the DataTablePagination goes wherever you want (for example, on top). --install-ui runs shadcn add with the lockfile's package manager and answers "no" to overwriting existing ui/ files; the registry URL is set in inertia-datatable.registry_url or with the INERTIA_DATATABLE_REGISTRY_URL variable (a URL or a local folder with the compiled JSON, for example to pin the UI to a version). The UI is installed even when the table is not created (unknown model or existing class). When the model does not exist, the command lists the available models.
Columns, formats and filters
Each column has a type, and the type only offers the methods that make sense for its values: a date has no money() and a number has no badge(), so the IDE and PHPStan catch a wrong combination. The row always carries the raw value; how it looks is declared on the column and formatted in the browser with Intl.
use InertiaDataTable\Column; Column::text('name', 'Name')->sortable()->searchable()->filterInput('Search name…'), Column::text('notes', 'Notes')->limit(60), Column::text('website', 'Web')->url(), // or ->email() Column::number('price', 'Price')->sortable()->money('CLP')->filterRange(), // slider, bounds from the data Column::number('weight', 'Weight')->unit('kg')->decimals(1)->filterInput(), Column::number('discount', 'Discount')->percent(), Column::number('views', 'Views')->format(['notation' => 'compact']), // any Intl option Column::date('published_at', 'Published')->format('long')->filterRange(), // short | medium | long | datetime | relative Column::boolean('featured', 'Featured')->labels('Sí', 'No')->filterSelect(), Column::option('status', 'Status')->options(ProductStatus::class)->badge()->filterMultiSelect(), Column::option('stage', 'Stage')->options(Stage::class)->badge('secondary', ['lost' => 'destructive']), Column::option('category', 'Category')->from('category.name')->sortable()->filterMultiSelect(),
| Type | Format | Filter |
|---|---|---|
Column::text() |
limit(), url() or email() |
filterInput(?placeholder): text input, contains |
Column::number() |
money(?currency, ?decimals), decimals(), percent(), format([...]), unit() |
filterInput(?placeholder): number input, equal to; filterRange(?min, ?max): slider |
Column::date() |
format(preset or Intl options) |
filterDate(): calendar, one day; filterRange(): calendar, from / to |
Column::boolean() |
labels(true, false), badge() |
filterSelect(): list, Yes or No |
Column::option() |
options(list, map or enum), badge() |
filterSelect(): list, one option; filterMultiSelect(): list, several (counts: false skips the counts) |
- The name of each filter method says the control shown in the toolbar. The URL also accepts other operators than the control uses (
filter[stock]=gt.100,between.10,50,is.empty…, seedocs/contract.md). empty: truein a list, range or date filter (filterRange(empty: true),filterMultiSelect(empty: true),filterSelect(empty: true),filterDate(empty: true)) offers the rows without a value: an option at the end of the list (with its count) or an "Include …" check under the slider or the calendar. They are added with OR to what is picked (published between January and March, or not published), or shown alone. The option is named afterwhenEmpty('Not published'), else "No value". It only makes sense on nullable columns and relations; in debug mode it throws on aNOT NULLcolumn, and on abelongsTocolumn whose foreign key and related column are bothNOT NULL. A value that the business calls empty but the database stores ('none',0) is a normal option or filter, notempty: true.- A column has at most one filter: calling two filter methods throws an error instead of keeping the last one. In the same way,
url()withemail()andhidden()withhideable(false)are errors. searchable()exists in text, number and option columns: the global search compares text, which says nothing useful about dates or booleans.- The unit of
unit('kg')goes after the number in the cells and next to the filter inputs (unit('kg', cell: false)orfilter: falsehides it on one side); withmoney(), the cells and the filter inputs show the currency symbol before the number ($1.200,[ $ | 1200 ]), in any locale;money('CLP', cell: 'code', filter: 'none')chooses separately for the cells (and the filter label) and the inputs:symbol,code(1.200 CLP,[ 1200 | CLP ]),both($1.200 CLP,[ $ | 1200 | CLP ], clearer when$could be more than one currency) ornone. In the cells, the code and the unit are smaller and muted.unit()together withmoney()orpercent()is an error, since they already say the unit. - An option column shows the labels of its declared options even without a filter; with a filter, the options carry the row counts.
- Numbers and dates use the table locale (see Language and formats);
money()without a currency usesinertia-datatable.currency, and dates withoutformat()useinertia-datatable.date_format. - Dates without time (Laravel
datecasts) never move to the previous day. - Cells without a value (
nullor'') show a muted—(theemptytext oflang/*/ui.php);->whenEmpty('Not published')on any column changes it, and->whenEmpty('')leaves them blank. Likeempty: true, in debug mode it throws on a column that can never be empty (NOT NULL). Empty meansNULL, not a business value: "out of stock" isstock = 0, a customcell(ormeta.cellClassName) in React. badge()uses the project's shadcnBadgewith any variant of itsbadge.tsx(outlineby default), for the column and per value. In boolean columns the values aretrueandfalse; withoutlabels(), booleans show Yes / No in the app language.- With
options(Enum::class), the enum can give the label (label()) and the badge variant (badgeVariant()) of each case, so every table that uses it looks the same:Column::option('status')->options(ProductStatus::class)->badge(). A variant passed tobadge('…', [...])wins. - In the React component you can set or override the format (
meta: { display: … }), add icons per value (meta: { icons: { active: CircleCheck } }, shown in the filter and in the cells of option and boolean columns) or write a customcellthat reuses the type cells (DataTableNumberCell,DataTableDateCell,DataTableOptionCell…) to render a value differently while keeping its format. Option cells takebadge(props of the same Badge per value) orrenderOption(anything else, such as a colored dot), andDataTableOptionBadgeis exported for fully custom cells. See the skill's React reference. - Column width and classes also live in the React component:
size: 280(px) or Tailwind classes, responsive too, inmeta: { className: 'w-40 md:w-80' }(headerClassName/cellClassNamefor one of them;cellClassNamecan be a function of the cell for classes by value). Alignment ismeta.align(start,centerorend; numbers areendby default), which moves the header and the cells together. Row height and row colors go in<DataTable headerClassName="[&>th]:h-8" rowClassName={(row) => …}>.
Language and formats
The defaults come from your Laravel app and each layer can change them; the most specific one wins.
| What | Order (first wins) |
|---|---|
| Locale of numbers, currencies and dates | useServerDataTable({ locale }) → the table (locale() in the class, ->locale() in DataTable::for) → inertia-datatable.locale → app()->getLocale() (APP_LOCALE) |
| UI texts (Search, Reset, Rows per page…) | useServerDataTable({ messages }) → lang/vendor/inertia-datatable/{lang}/ui.php → the package texts (en, es) in the app language → English |
| Format of a column | your own cell → React meta.display → the column method (->money(), ->format()…) → inertia-datatable.currency / inertia-datatable.date_format |
// config/inertia-datatable.php 'locale' => null, // null = app locale; 'es-CL', 'en-US'…; 'browser' = each browser's 'currency' => 'CLP', // ->money() without a currency 'date_format' => 'long', // date columns without ->format()
- The locale gives the separators: with
esores-CLa CLP amount shows$1.234, withenit shows$1,234.es_CLis accepted too. - The calendar of the date filters follows the same locale (month and day names, first day of the week).
- To translate or change the texts, publish them and edit the copy; it survives
inertia-datatable:ui --update:
php artisan vendor:publish --tag=inertia-datatable-lang # → lang/vendor/inertia-datatable/{en,es}/ui.php
Add a language by copying en/ui.php to lang/vendor/inertia-datatable/{lang}/ui.php; keys missing in a file fall back to English. In React, useServerDataTable({ messages: { search: 'Find products…' } }) changes texts for one table.
Column validation
With APP_DEBUG=true, every request checks that the declared columns exist, so a typo or a missing migration fails on the first load with a clear message instead of a SQL error when someone sorts or filters:
- Columns that sort, search or filter (and the default sort) must be columns of their table (or of the related table, with
from('relation.column')). - Displayed columns must have a value in the model: a column, an alias of the query (
withCount,addSelect) or an attribute in$appends. Tables withtransform()are not checked.
It is controlled with inertia-datatable.validate_columns (null = follow the debug mode, true/false to force it). In production it is off and costs nothing.
Installing or updating the UI
php artisan inertia-datatable:ui # installs the generic UI if missing php artisan inertia-datatable:ui --update # updates only the DataTable files (asks for confirmation; --force to skip it)
The update uses the data-table-react-files item, which overwrites components/data-table/*, components/ui/faceted.tsx, the hook and lib/data-table-*.ts, without touching the rest of the project's components/ui/. Local changes to those files are lost.
The stubs can be customized with php artisan vendor:publish --tag=inertia-datatable-stubs.
Frontend installation (React)
In a project with Laravel's React starter kit:
php artisan inertia-datatable:ui # or make:datatable … --install-ui php artisan inertia-datatable:ui --update # brings the new version of the DataTable files
From the registry of the same version as the package, it installs @tanstack/react-table v9, the useServerDataTable hook, the components in resources/js/components/data-table/ and the missing shadcn primitives, answering "no" to overwriting the existing ui/ files (the DataTable only uses variants the starter kit already has). By hand:
pnpm dlx shadcn@latest add https://raw.githubusercontent.com/eduardoapatat/laravel-inertia-datatable/v0.9.0/r/data-table-react.json
Inertia writes the URL with brackets and commas encoded (filter%5Bstatus%5D=a%2Cb). For readable URLs, add this to the app's HandleInertiaRequests middleware (it affects every Inertia page, so the package does not do it by itself):
public function urlResolver(): ?Closure { return fn (Request $request): string => $request->getRequestUri(); }
For usage in pages, see make:datatable --react above and the skill's React reference (skill/laravel-inertia-datatable/references/react.md).
Claude Code skill
So Claude Code knows how to install the package and create tables in a project:
pnpm dlx shadcn@latest add https://raw.githubusercontent.com/eduardoapatat/laravel-inertia-datatable/v0.9.0/r/data-table-skill.json
It is placed in .claude/skills/laravel-inertia-datatable/.
Development
composer install composer test # Pest composer format # Pint composer analyse # Larastan composer check # all of the above (Pint in --test mode) pnpm install pnpm run test # tests of the shared code (Vitest) pnpm run check # format and lint (vite-plus) pnpm run types:check pnpm run registry:build # compiles registry.json into r/
Tests use in-memory SQLite; for another engine pass Testbench's variables, for example DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing DB_USERNAME=postgres DB_PASSWORD=password vendor/bin/pest.
The CI (.github/workflows/) runs:
- tests: Pest with PHP 8.3 to 8.5 and Laravel 12 and 13, MySQL 8.4 and Postgres 17, and the registry (format, tests, types and that
r/is rebuilt). - starter kit: creates a clean React starter kit, installs the package and the UI of the commit with
make:datatable --react --page --install-ui(with npm and with pnpm) and runs the starter kit'scomposer ci:check. It also runs every week, because the installer usesshadcn@latest. It can be run locally with.github/scripts/starter-kit.sh npm /tmp/app.
Releasing a version
- Move the
## [Unreleased]entries ofCHANGELOG.mdto the new version. - Change the version in the registry URLs of the README and the skill (
…/vX.Y.Z/r/…) and rebuildr/. - Commit, annotated tag
vX.Y.Zand push the tag. Packagist publishes it by itself;inertia-datatable:uiinstalls the UI of that tag.
Credits
Part of the frontend code includes third-party MIT-licensed software; see THIRD-PARTY-NOTICES.md.