alemian95 / laraveldatatable
Server-side datatables for Laravel: search, sort, filter and paginate any Eloquent or Query Builder query from HTTP parameters, with a React companion package.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^11.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 11:41:43 UTC
README
A lightweight, server-side datatable query layer for Laravel. Wrap any Eloquent or Query Builder instance and get standardized JSON pagination, search, sorting and filtering driven by HTTP request parameters — with hooks to override each step.
Requirements
- PHP
^8.3 - Laravel
^12.0 || ^13.0
Installation
Install via Composer:
composer require alemian95/laraveldatatable
The service provider is auto-discovered (AleMian95\Datatable\DatatableServiceProvider) — no manual registration required.
Frontend (React)
If you want the ready-made React datatable, run the install command:
php artisan datatable:install
It installs the @alemian95/laraveldatatable-react npm package (detecting npm / pnpm / yarn / bun from your lockfile), publishes the config file, offers the Agent Skill, warns about missing peer dependencies (react, react-dom, tailwindcss), and prints the remaining setup steps. The frontend is optional — the backend works on its own. See the React package README for the component API.
AI coding agents
The package ships an Agent Skill: instructions that teach coding agents (Claude Code, Codex, Cursor, Copilot, Gemini, Junie) its public API, the HTTP contract and the React component. Agents load it only when a task touches the package.
-
With Laravel Boost: run
php artisan boost:install(orboost:update) and selectalemian95/laraveldatatable. Boost copies the skill into each agent's folder and refreshes it onboost:update. -
Without Boost:
datatable:installoffers to copy it into.claude/skills/laraveldatatable/and.agents/skills/laraveldatatable/. After updating the package, refresh the copies with:php artisan vendor:publish --tag=laraveldatatable-skill --force
Local edits to the copies are overwritten by the refresh.
Optionally, publish the config file to override defaults:
php artisan vendor:publish --tag="laraveldatatable-config"
This is the contents of the published config/laraveldatatable.php:
return [ 'default' => [ // Default page size used when the request omits "per_page". 'per_page' => 15, // Hard upper bound: a larger "per_page" is clamped down to this cap. 'max_per_page' => 100, ], 'debug' => [ // When true, the interpolated SQL of each query is logged at "info". // Off by default: the SQL can contain the raw search term (PII). 'log_sql' => false, ], 'search' => [ // When true: fall back to Schema introspection if no whitelist is declared. // When false: declaring HasSearchableColumns or withSearchableColumns() is mandatory. 'auto_discover_columns' => false, // Column names / wildcard patterns always excluded from auto-discovery. 'auto_discovery_blacklist' => [ 'password', 'remember_token', 'api_token', '*_token', '*_secret', '*_hash', '*_key', ], ], ];
Usage
The main entry point is AleMian95\Datatable\DatatableApi. Create it with DatatableApi::for($query) and return it from a controller: it is Responsable, so Laravel turns it into a paginated JSON response. Call toPaginator() to get the paginator (or resource collection) directly, e.g. as an Inertia prop. Each call runs on a clone of your query, so the same instance can be executed more than once.
Quick start
use AleMian95\Datatable\DatatableApi; use App\Models\User; public function index() { return DatatableApi::for(User::query()); }
That call already paginates. Search and sort are opt-in: declare what the client may use with withSearchableColumns() (or the HasSearchableColumns contract on the model) and withSortableColumns(). Anything undeclared is ignored with a log warning.
HTTP request contract
DatatableRequest parses the following parameters from the incoming request:
| Parameter | Type | Default | Purpose |
|---|---|---|---|
search |
string | null |
Free-text term applied with case-insensitive LIKE %term%. |
search_columns |
csv string | auto-resolved (see Advanced) | Columns to search in. Supports dot-notation relation.column. |
sort_by |
string | null |
Column to sort by. Supports dot-notation for BelongsTo relations. |
sort_order |
asc | desc |
asc |
Sort direction. |
per_page |
int | config('laraveldatatable.default.per_page', 15) |
Results per page. |
page |
int | 1 |
Page number. |
filter[<key>] |
string | [from]/[to] |
none | Client filter; applied only when <key> is declared via withFilters(). |
Example request:
GET /api/users?search=jane&search_columns=first_name,last_name,email&sort_by=created_at&sort_order=desc&per_page=25
Response shape
Without a resource, the response is a standard Laravel length-aware paginator:
{
"current_page": 1,
"data": [ { "id": 1, "name": "Jane Doe", "...": "..." } ],
"first_page_url": "...",
"from": 1,
"last_page": 5,
"last_page_url": "...",
"links": [ /* ... */ ],
"next_page_url": "...",
"path": "...",
"per_page": 15,
"prev_page_url": null,
"to": 15,
"total": 75
}
When returnResource(ResourceClass::class) is used, the paginator is wrapped in ResourceClass::collection($paginator), producing the conventional { "data": [...], "links": {...}, "meta": {...} } envelope.
Customizing the pipeline
Each builder method below returns $this, so they can be chained freely.
DatatableApi::for(Builder $query, ?Request $request = null): self— accepts an Eloquent builder, aRelation, or a baseQueryBuilder. Without$requestthe current request is used; pass one explicitly in jobs, commands and tests.withCustomSearch(Closure $search): self— overrides the default LIKE/auto-column search. The closure receives($builder, string $term)and is responsible for the full search clause.withCustomSorts(array $sorts): self— map ofsort_byvalue →Closure($builder, string $direction). Triggered only when the incomingsort_bymatches a key; otherwise the default sort logic runs.withSortableColumns(array $columns): self— authoritative whitelist for thesort_byparameter (dot-notation entries included, e.g.author.name). Asort_byoutside the list is dropped with a warning instead of reaching the database; keys declared viawithCustomSorts()are always allowed. Without it, onlywithCustomSorts()keys are sortable.withFilters(array $filters): self— map offilter[<key>]name →Closure($builder, string|array $value). A closure runs only when its key is present; range filters receive['from' => ?string, 'to' => ?string]. Type the value asstringorarrayto accept only that shape: a value of the other shape is ignored like a malformed one, instead of reaching the closure. Malformed values and undeclared keys are ignored with a log warning. Fixed constraints (tenant, active scope) belong on the query you pass tofor().withSearchableColumns(array $columns): self— declares the authoritative whitelist of columns the search can target for this instance. Wins over theHasSearchableColumnscontract on the model and is the only way to enable search on a rawQueryBuilderwhenauto_discover_columnsisfalse. When set,search_columnsfrom the request is intersected against this whitelist.returnResource(string $resourceClass): self— fully-qualified API Resource class name. Output is wrapped viaResource::collection($paginator).
Every with* method replaces the previous declaration; the last call wins.
Full chained example:
use AleMian95\Datatable\DatatableApi; use App\Http\Resources\UserResource; use App\Models\User; public function index() { return DatatableApi::for( User::query()->where('active', true)->with('profile', 'role') ) ->withCustomSorts([ 'full_name' => fn ($builder, $direction) => $builder->orderByRaw("CONCAT(first_name, ' ', last_name) {$direction}"), ]) ->withSortableColumns(['created_at', 'email']) ->withFilters([ 'status' => fn ($builder, string $value) => $builder->where('status', $value), 'created_at' => fn ($builder, array $range) => $builder ->when($range['from'], fn ($q, $from) => $q->whereDate('created_at', '>=', $from)) ->when($range['to'], fn ($q, $to) => $q->whereDate('created_at', '<=', $to)), ]) ->returnResource(UserResource::class); }
Searchable columns
The set of columns that can be searched is resolved in this order:
DatatableApi::withSearchableColumns(['col_a', 'col_b'])— wins over everything.Model implements HasSearchableColumns— the contract returns the whitelist (the traitConcerns\HasSearchableColumnsreads aprotected array $searchable = [...]property by default).- Auto-discovery via
Schema::getColumns()— fallback only whenconfig('laraveldatatable.search.auto_discover_columns')istrue(off by default since 0.9). Filters out non-string columns and applies theauto_discovery_blacklist. When the request suppliessearch_columnsin this branch, they are intersected against the auto-discovery result — so the type filter and the blacklist also protect against client-supplied column names.
When a whitelist is declared, search_columns from the HTTP request is intersected against it: the client can never broaden it. An empty whitelist (withSearchableColumns([]) or protected array $searchable = []) is treated as an authoritative signal to omit the search clause entirely — no LIKE is applied, the dataset is returned unfiltered by the search term (pagination, sorting and other filters still apply), and there is no fallback to the next source. When no source can satisfy the request and auto-discovery is off, the search is ignored with a log warning: the request still succeeds, unfiltered by the term.
Example with the trait:
use AleMian95\Datatable\Contracts\HasSearchableColumns; use AleMian95\Datatable\Concerns\HasSearchableColumns as HasSearchableColumnsTrait; class User extends Model implements HasSearchableColumns { use HasSearchableColumnsTrait; protected array $searchable = ['first_name', 'last_name', 'email', 'profile.bio']; }
Example with the per-request override (works for both Eloquent and raw QueryBuilder):
return DatatableApi::for(DB::table('users')) ->withSearchableColumns(['name', 'email']);
To make declaration mandatory project-wide, set in config/laraveldatatable.php:
'search' => [ 'auto_discover_columns' => false, ],
Resolver lifecycle. The SearchColumnResolver is bound to the container as a scoped instance, so each HTTP request / queue job receives a fresh resolver built from the current laraveldatatable.search.* values. This means multi-tenant setups that swap the config per request get the expected per-tenant behavior with no extra work. For the rare case of changing the config mid-request (e.g. inside tests), call app()->forgetInstance(\AleMian95\Datatable\Contracts\SearchColumnResolver::class) after the change to force re-resolution.
Relational search
When search_columns contains a dot — author.name, tags.label — the package needs to know how to resolve the relation segment (author, tags) into SQL.
On Eloquent builders the relation is auto-discovered from the model. No extra configuration is needed:
return DatatableApi::for(Book::query()) ->withSearchableColumns(['title', 'author.name', 'tags.label']);
Supported relation types via auto-discovery: BelongsTo, HasOne, HasMany, BelongsToMany. Other relation types (MorphTo, HasManyThrough, …) need an explicit declaration via withRelationSearch() using RelationSearch::custom().
On a raw QueryBuilder there is no model to introspect, so the relation must be declared explicitly:
use AleMian95\Datatable\Search\RelationSearch; return DatatableApi::for(DB::table('books')) ->withSearchableColumns(['title', 'author.name']) ->withRelationSearch([ 'author' => RelationSearch::belongsTo('authors'), ]);
Smart defaults follow the Laravel conventions: belongsTo('authors') assumes author_id and id. Override only when the schema diverges:
->withRelationSearch([ 'author' => RelationSearch::belongsTo('writers', localKey: 'written_by', remoteKey: 'uuid'), 'publisher' => RelationSearch::hasOne('publishers', foreignKey: 'book_isbn', localKey: 'isbn'), 'tags' => RelationSearch::belongsToMany('tags', pivot: 'book_tag'), ])
A declared spec wins over Eloquent auto-discovery for the same relation key, which is the right tool to inject custom scopes (soft-delete filtering, tenant constraints) without rewriting the whole search:
->withRelationSearch([ 'author' => RelationSearch::custom(function ($query, $remoteColumn, $term) { $query->orWhereExists(fn ($sub) => $sub->from('authors') ->whereColumn('authors.id', 'books.author_id') ->whereNull('authors.deleted_at') ->whereLike("authors.{$remoteColumn}", "%{$term}%") ); }), ])
Multi-hop dotted paths (book.author.country.name) are resolved automatically on Eloquent via the existing orWhereHas chain. On raw QueryBuilder multi-hop is unsupported — the column is dropped with a Log::warning.
Generated SQL uses orWhereExists with explicit key joins (and an inner join for belongsToMany). Columns are always qualified table.column to avoid ambiguity with the base table.
Migrating from older versions that already used Eloquent dot-notation search: the row set returned by the package is unchanged. The SQL emitted internally changes from orWhereHas(...) to orWhereExists(...); this is visible in query logs and any test that asserts on the raw SQL text, but is invisible at the result-set level.
Known limits
-
Multi-hop dot-notation on raw
QueryBuilder. Single-hop paths (author.name) work on both Eloquent and raw queries — see Relational search. Multi-hop paths (author.country.name) are supported only on Eloquent (resolved viaorWhereHas); on a rawQueryBuilderthey are dropped with aLog::warning. -
Relational sorting supports
BelongsToonly. Forsort_by=author.name,SortApplierperforms aleftJoinon eachBelongsTosegment and then orders by the joined column. A path whose segments are not allBelongsTois dropped with a log warning. Expose such sorts viawithCustomSorts(...). -
Joined columns on a raw
QueryBuilderneed a custom search. Plain search columns are qualified with the base table (order_items.name), and a dotted entry always means a relation, nevertable.column. To search a joined table, usewithCustomSearch():DatatableApi::for(DB::table('order_items')->join('products', 'products.id', '=', 'order_items.product_id')) ->withCustomSearch(fn ($q, string $term) => $q->whereLike('products.name', "%{$term}%"));
-
SQL logging is opt-in. With
laraveldatatable.debug.log_sqlset totrue, each assembled query is written to the log atinfolevel. It is off by default because the interpolated SQL contains the raw search term.
Versioning
The package follows semantic versioning. The public API, the one a minor or patch release never breaks, is listed in ADR 0002:
DatatableApi(final, created withDatatableApi::for()),Search\RelationSearch, theHasSearchableColumnscontract and trait,SearchColumnsNotConfiguredException, the config keys and thedatatable:installcommand;- the HTTP contract: the query parameters and the two response envelopes above;
- every export of the React package.
Everything marked @internal (appliers, resolvers, DatatableRequest, …) can change in any release. The Composer package and the npm package are released together with the same version number.
Testing
composer test
The suite runs on in-memory SQLite by default. To run it against MySQL or Postgres, point it at a server with DB_CONNECTION (mysql | pgsql), plus DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD as needed. The defaults are 127.0.0.1, testing, root/postgres and secret:
DB_CONNECTION=pgsql DB_PORT=5432 composer test
CI runs it on SQLite, MySQL 8.4 and Postgres 17.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.