syscage / laravel-datatable
A fluent, extensible Laravel datatable package for Eloquent models, query builders, collections and arrays, driven automatically by HTTP request headers.
Requires
- php: ^8.2|^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/pipeline: ^12.0| ^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.17
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
README
A fluent, extensible datatable package for Laravel. Write ordinary Laravel query code — Eloquent models, query builders, collections, or plain arrays — and get automatic, header-driven searching, sorting, filtering, pagination, and response formatting for free.
use Syscage\Datatable\Facades\Syscage; return Syscage::datatable(User::class) ->search(['name', 'email', 'roles.name']) ->sortable(['id', 'name', 'created_at']) ->filter(['status', 'country']) ->where('is_active', true) ->latest();
No paginate(), no manual query parsing, no hand-rolled JSON shape. The request headers do the driving; the package does the rest.
Table of Contents
- Installation
- Configuration
- Basic Usage
- Request Headers
- Search
- Sorting
- Filtering
- Pagination
- Working with Collections and Arrays
- API Resources
- Transform
- Append, Only and Hidden
- Response Shape
- Method Forwarding
- Macros
- Advanced Usage
- Examples
- FAQ
- Upgrade Guide
- Testing
- Contributing
- License
Installation
Requires PHP 8.3+ and Laravel 11, 12 or 13.
composer require syscage/laravel-datatable
The service provider and Syscage facade are auto-discovered — there is nothing else to register.
Publish the config file if you want to customize headers, defaults, or the response shape:
php artisan vendor:publish --tag=datatable-config
This publishes config/datatable.php.
Configuration
return [ 'headers' => [ 'search' => 'X-SC-Datatable-Search', 'rows' => 'X-SC-Datatable-Rows', 'page' => 'X-SC-Datatable-Page', 'sort' => 'X-SC-Datatable-Sort', 'order' => 'X-SC-Datatable-Order', 'filters' => 'X-SC-Datatable-Filters', ], 'default_rows' => 15, 'max_rows' => 100, 'default_order' => 'asc', 'response' => [ 'wrapper' => 'datatable', 'data_key' => 'data', 'meta_key' => 'meta', ], ];
- headers — rename any header to avoid clashing with headers your app already uses.
- default_rows — rows returned per page when the rows header is absent.
- max_rows — hard ceiling a request can never exceed, regardless of what it asks for.
- default_order — used when a sort column is requested without a valid order.
- response.wrapper — the top-level key everything is nested under. Set to
nullto returndata/metaunwrapped at the response root. - response.data_key / response.meta_key — rename the
dataandmetakeys.
Basic Usage
Build a datatable from anything: a model class name, a model instance, an Eloquent builder, an Eloquent relation, a query builder, a collection, a lazy collection, or a plain array.
use Syscage\Datatable\Facades\Syscage; // From a model class name return Syscage::datatable(User::class); // From an already-built Eloquent query return Syscage::datatable(User::query()->where('status', 1)); // From a relation return Syscage::datatable($company->users()); // From the query builder return Syscage::datatable(DB::table('users')); // From a collection return Syscage::datatable(User::all()); // From a lazy collection return Syscage::datatable(User::cursor()); // From a plain array return Syscage::datatable($rows);
You can also instantiate Datatable directly instead of going through the facade:
use Syscage\Datatable\Datatable; return new Datatable(User::class);
Returning a Datatable instance directly from a controller action automatically renders it as JSON — it implements Illuminate\Contracts\Support\Responsable. You can also call ->response() for an explicit JsonResponse, or ->get() / ->toArray() for the raw payload array.
Request Headers
Every request-dependent behaviour — search keyword, current page, rows per page, sort column/direction, and filters — is read from HTTP headers, not query parameters or body input. This keeps the payload contract identical for GET, POST, or any other verb, and keeps datatable state out of the URL.
| Header | Purpose | Example |
|---|---|---|
X-SC-Datatable-Search |
Free-text search keyword | ada |
X-SC-Datatable-Rows |
Rows per page | 25 |
X-SC-Datatable-Page |
Current page (1-indexed) | 2 |
X-SC-Datatable-Sort |
Column to sort by | created_at |
X-SC-Datatable-Order |
asc or desc |
desc |
X-SC-Datatable-Filters |
JSON object of column filters | {"status":"active","country":"MY"} |
Every header is optional. Rows falls back to default_rows (clamped to max_rows), page falls back to 1, and order falls back to default_order.
You can read these values yourself via Syscage\Datatable\Http\DatatableRequest:
use Syscage\Datatable\Http\DatatableRequest; public function index(DatatableRequest $request) { $request->getKeywords(); $request->getRows(); $request->getPage(); $request->getSort(); $request->getOrder(); $request->getFilters(); }
Search
Approve which columns participate in free-text search. A grouped OR WHERE ... LIKE clause is generated automatically:
Syscage::datatable(User::class)->search(['name', 'email', 'phone']);
Relationship Search
Dotted columns on an Eloquent source are automatically treated as relationships and resolved with whereHas:
Syscage::datatable(User::class)->search(['name', 'roles.name', 'company.name']);
Searching roles.name matches any user with at least one role whose name contains the keyword.
Custom Search Callback
For anything the grouped-LIKE behaviour can't express, pass a closure instead. It receives the underlying query builder and the raw keyword:
Syscage::datatable(User::class)->search(function ($query, string $keyword) { $query->whereRaw('MATCH(name, bio) AGAINST (? IN BOOLEAN MODE)', [$keyword]); });
On collection and array sources, the callback instead receives the item and the keyword, and must return a
bool— see Working with Collections and Arrays.
Sorting
Only columns you explicitly approve can be sorted by; everything else is silently ignored, which keeps user-controlled input from ever reaching ORDER BY unchecked:
Syscage::datatable(User::class)->sortable(['id', 'name', 'created_at']);
Requesting X-SC-Datatable-Sort: created_at with X-SC-Datatable-Order: desc sorts accordingly. Requesting a column not in the list (e.g. password) is ignored entirely — the result is returned in its natural order.
Filtering
Register filterable columns, then supply values via the X-SC-Datatable-Filters header as a JSON object:
Syscage::datatable(User::class)->filter(['status', 'country', 'role']);
X-SC-Datatable-Filters: {"status":"active","country":"MY"}
Only columns that were registered with filter() and present in the header are applied — unregistered keys in the JSON payload are ignored.
A single column can be registered on its own:
Syscage::datatable(User::class)->filter('status');
Custom Filter Callback
Syscage::datatable(User::class)->filter('status', function ($query, $value) { $query->where('status', $value)->orWhere('legacy_status', $value); });
On collection and array sources, the callback receives the item and the value instead of a query builder, and must return a
bool.
Pagination
Pagination is always automatic — you never call paginate() yourself. Rows and page come straight from the request headers, and the underlying query is only ever asked for a single page's worth of rows.
return Syscage::datatable(User::class);
X-SC-Datatable-Rows: 25
X-SC-Datatable-Page: 2
When the requested page is beyond the last page, it's clamped down to the last valid page rather than returning an error or an empty page. When there are zero matching rows, the pagination query is skipped entirely.
Working with Collections and Arrays
Collections, lazy collections, and arrays are supported with the exact same fluent API. Search, filter, and sort run in memory, and pagination uses slice() rather than a database query:
Syscage::datatable($usersCollection) ->search(['name', 'email']) ->sortable(['name']) ->filter('status');
Dotted search/filter columns (e.g. roles.name) work here too: if the resolved value at any segment of the path is itself iterable (a loaded relationship collection, a nested array of records), the remaining path is matched against every element, and the column counts as a match if any element matches.
Custom search/filter callbacks for these sources receive the item itself, not a query builder:
Syscage::datatable($usersCollection)->search(function ($item, string $keyword) { return str_contains(strtolower($item['name']), strtolower($keyword)); }); Syscage::datatable($usersCollection)->filter('status', function ($item, $value) { return $item['status'] === $value; });
API Resources
Transform every item through a Laravel API resource:
Syscage::datatable(User::class)->resource(UserResource::class);
Transform
Or transform every item through a plain closure, returning the array you want in the response:
Syscage::datatable(User::class)->transform(fn (User $user) => [ 'id' => $user->id, 'name' => strtoupper($user->name), ]);
resource() and transform() are mutually exclusive per call — whichever was configured last is used. When neither is configured, every item is converted via toArray() (Eloquent models, Arrayable objects) or a plain array cast.
Append, Only and Hidden
Append computed keys to every item, after resource/transform has run:
Syscage::datatable(User::class)->append([ 'is_admin' => fn (User $user) => $user->hasRole('admin'), ]);
Restrict the output to specific keys:
Syscage::datatable(User::class)->only(['id', 'name']);
Or remove specific keys instead:
Syscage::datatable(User::class)->hidden(['password', 'remember_token']);
append() runs first, so an appended key survives a subsequent only() call if you list it explicitly.
Response Shape
Every response is returned as:
{
"datatable": {
"data": [],
"meta": {
"page": 1,
"rows": 15,
"total": 42,
"pages": 3,
"from": 1,
"to": 15,
"count": 15,
"has_next": true,
"has_prev": false,
"search": "",
"sort": null,
"order": "asc",
"execution_ms": 4.32
}
}
}
Set response.wrapper to null in the config to get an unwrapped {"data": [...], "meta": {...}} payload instead, or rename data_key / meta_key to whatever your frontend expects.
Method Forwarding
Any method not defined on Datatable is forwarded straight to the underlying source, so it feels exactly like writing plain Laravel query code:
Syscage::datatable(User::class) ->where('status', 1) ->whereNotNull('email_verified_at') ->latest();
Methods that mutate the query/collection and return it (e.g. where, orderBy, latest, collection's map/filter) keep the fluent chain going by returning the Datatable instance. Methods that return something else entirely (e.g. count(), exists(), sum()) return that value directly, breaking the chain — exactly as you'd expect.
Macros
Register your own reusable methods on Datatable:
use Syscage\Datatable\Datatable; Datatable::macro('onlyActive', function () { return $this->where('status', 'active'); }); Syscage::datatable(User::class)->onlyActive()->get();
Advanced Usage
Every fluent method can be combined:
return Syscage::datatable(User::class) ->search(['name', 'email', 'roles.name']) ->sortable(['id', 'name', 'created_at']) ->filter(['status', 'country']) ->filter('role', fn ($query, $value) => $query->whereHas('roles', fn ($q) => $q->where('slug', $value))) ->resource(UserResource::class) ->append(['is_admin' => fn (User $user) => $user->hasRole('admin')]) ->hidden(['password']) ->where('is_active', true) ->with('company') ->latest();
Because the pipeline is built from small, single-responsibility pipe classes (SearchPipe, FilterPipe, SortPipe, PaginationPipe, TransformPipe, ResponsePipe), and every driver implements the same DriverInterface, you can extend the package by writing your own driver or by macro-ing new fluent methods onto Datatable.
Examples
A typical index endpoint:
class UserController { public function index() { return Syscage::datatable(User::class) ->search(['name', 'email']) ->sortable(['id', 'name', 'email', 'created_at']) ->filter(['status', 'country']) ->resource(UserResource::class); } }
Filtering an in-memory report built from an array:
public function report() { $rows = Report::summaryRows(); // array<int, array<string, mixed>> return Syscage::datatable($rows) ->search(['label']) ->sortable(['label', 'total']) ->filter('category'); }
FAQ
Do I need to call paginate() myself?
No. Pagination always happens automatically, driven by the X-SC-Datatable-Rows and X-SC-Datatable-Page headers.
Can I use query string parameters instead of headers?
Not directly — the package is header-driven by design so the same contract works regardless of HTTP verb. If you need query-string support, read the value yourself (e.g. $request->query('page')) and set the corresponding header before resolving the datatable, or extend DatatableRequest.
What happens if I request an invalid sort column? It's silently ignored. The result is returned in its natural (unsorted, or database-default) order.
What happens if I filter on a column I never registered with filter()?
It's ignored — only registered, filterable columns are ever applied, even if the request's JSON filters payload contains other keys.
Can I combine resource() and transform()?
No — configure one or the other. Whichever you call last wins.
Does this hydrate the whole table into memory?
No. For Eloquent/query builder sources, only a single page of rows is ever fetched — search, filter and sort all happen at the SQL layer, and the row count uses COUNT(*), not a fully-hydrated collection.
Upgrade Guide
This is the initial 1.x release. Future upgrade notes will be documented here as breaking changes are introduced.
Testing
composer test
composer analyse
composer format
Contributing
Issues and pull requests are welcome. Please ensure composer test, composer analyse (PHPStan, max level), and composer format (Pint) all pass before submitting.
License
The MIT License (MIT). See LICENSE.md for more information.