amjadiqbal / laravel-gridjs
Grid.js datagrid for Laravel: turn any Eloquent query into a searchable, sortable, paginated data table with server-side processing, a fluent PHP API and no jQuery.
Requires
- php: ^8.3
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Grid.js: server-side datagrid and data table for Eloquent
Turn any Eloquent query into a searchable, sortable, paginated Grid.js data table with one fluent PHP call. Search, sort and pagination run on the server, so a table of a million rows loads one page at a time. A lightweight DataTables alternative: no jQuery, no npm build step.
Docs and demo: https://amjad.com.pk/laravel/packages/laravel-gridjs
use AmjadIqbal\GridJS\Facades\Grid; $customers = Grid::fromQuery(Customer::active()->with('team')->latest('signed_up_at')) ->columns([ 'name', 'email', ['field' => 'team.name', 'name' => 'Team'], ['field' => 'mrr', 'name' => 'MRR', 'format' => ['type' => 'currency', 'currency' => 'USD']], ['field' => 'signed_up_at', 'name' => 'Signed up', 'format' => ['type' => 'date']], ]) ->searchable() ->sortable() ->pagination(10) ->actions([['label' => 'Edit', 'url' => '/customers/{id}/edit']]); return view('customers.index', ['customers' => $customers]);
{{ $customers }}
That is the whole integration. No route to write, no controller, no JavaScript.
Why this package
- Your query, kept. Where clauses, scopes, joins, eager loads and the default order of the query you
pass in are exactly what the table shows.
Customer::active()shows only active customers. - Server-side everything. Grid.js asks for one page at a time; search (
LIKEacross the searchable columns, relation columns included), sort andLIMIT/OFFSETrun in your database with the real total. - Secure by design. The data endpoint never takes a model or column name from the browser. Each
grid carries an encrypted token describing its query and columns, so the endpoint can only return what
you rendered. Put it behind
authwith one config line. - Safe output. Cell values are escaped; built-in link and action formatters escape values and refuse
javascript:URLs. - Grid.js 6.2.0, pinned. Loaded from jsDelivr with Subresource Integrity, or served from your own
public/folder with the copy bundled in the package.
Requirements
- PHP 8.3, 8.4 or 8.5
- Laravel 12 or 13
Installation
composer require amjadiqbal/laravel-gridjs
That's it: the service provider, the Grid facade and the data route register themselves. Optionally:
php artisan gridjs:install # publish config/gridjs.php, choose CDN or local assets php artisan gridjs:publish-assets # copy Grid.js into public/vendor/gridjs (then GRIDJS_CDN=false)
Usage
Columns
->columns([ 'name', // heading "Name" 'created_at', // heading "Created At" 'team.name', // a relation column ['field' => 'email', 'name' => 'E-mail', 'width' => '240px'], ['field' => 'full_name', 'searchable' => false, 'sortable' => false], // an accessor ])
Per column: field, name, width, hidden, searchable, sortable, format and formatter.
Accessors and casts work in cells; they can't be searched or sorted in SQL, so switch those off for them.
Relation columns are searchable (through whereHas) but not sortable.
A column marked 'hidden' => true never leaves the server: its values are left out of the JSON, and it
can't be searched or sorted (a search box over it would let anyone probe its values). The one exception
is the key column of an actions() column, whose value is in the action links anyway.
Search, sort and pagination
->searchable() // every data column ->searchable(['name', 'email'], 200) // only these, 200 ms debounce ->sortable() // every plain column ->sortable(['name', 'created_at']) // only these ->pagination(25) // 25 per page (default: config gridjs.per_page)
The browser can only pick a page, a keyword and one of the sortable fields. Anything else is ignored.
A grid without pagination() returns at most gridjs.max_rows rows (1000).
Formats
['field' => 'joined_at', 'format' => ['type' => 'date', 'locale' => 'en-GB']] ['field' => 'paid_at', 'format' => ['type' => 'datetime']] ['field' => 'total', 'format' => ['type' => 'currency', 'currency' => 'EUR', 'locale' => 'de-DE']] ['field' => 'views', 'format' => ['type' => 'number']] ['field' => 'active', 'format' => ['type' => 'boolean', 'true' => 'Yes', 'false' => 'No']] ['field' => 'website', 'format' => ['type' => 'link', 'target' => '_blank']] ['field' => 'slug', 'format' => ['type' => 'link', 'url' => '/posts/{value}']]
Or write your own Grid.js formatter in JavaScript (it runs as-is, so never build it from user input):
['field' => 'status', 'formatter' => '(cell) => cell.toUpperCase()'] ['field' => 'avatar', 'formatter' => '(cell) => gridjs.html(`<img src="${LaravelGridJS.escape(cell)}" width="24">`)']
Action buttons
->actions([ ['label' => 'View', 'url' => '/customers/{id}'], ['label' => 'Edit', 'url' => '/customers/{id}/edit', 'class' => 'btn btn-sm'], ['label' => 'Open', 'url' => '/c/{id}', 'target' => '_blank'], ]) ->actions([['label' => 'Show', 'url' => '/orders/{id}']], key: 'uuid') // {id} = the uuid field
{id} is filled from the key field (default id). If you don't show that field it is still sent to the
browser (it has to be, to build the links), as a hidden column.
Look and layout
->theme('mermaid') // Grid.js's own theme (the default) ->theme(null) // unstyled ->className([ // Tailwind or your own CSS 'table' => 'w-full text-sm', 'th' => 'px-3 py-2 text-left bg-slate-50', 'td' => 'px-3 py-2 border-t', ]) ->resizable() ->fixedHeader(height: '400px') ->width('100%') ->language(['search' => ['placeholder' => 'Find a customer...']]) ->options(['autoWidth' => false]) // any other Grid.js option ->id('customers-table')
Assets
By default the first grid on a page prints the Grid.js script and theme (jsDelivr, pinned to 6.2.0 with integrity hashes) plus a 2 KB mount script. Assets print once per request, however many grids there are.
- Prefer your own
public/folder:php artisan gridjs:publish-assetsandGRIDJS_CDN=false. - Prefer them in your layout's
<head>: add@gridjsAssets(or@gridjsAssets(false)for no theme). - Loading Grid.js yourself (Vite)? Set
gridjs.assets.injecttofalseand exposewindow.gridjs. - Using a Content Security Policy? Call
Vite::useCspNonce()and the inline scripts get the nonce.
The grid element fires a gridjs:ready event with the Grid.js instance in event.detail.grid.
Protecting the data
Every grid fetches from GET /gridjs/data. Add auth (or any middleware) to keep it behind login:
// config/gridjs.php 'middleware' => ['web', 'auth'],
Each grid's token is encrypted with your APP_KEY and is only valid:
- for the person it was rendered for: the signed-in user, or for guests the browser session. A token
copied to another account or browser gets a 403. This needs the session (the
webmiddleware). A grid rendered with neither a signed-in user nor a session throws aRuntimeExceptionrather than issue a token anyone could reuse. To allow that anyway, setallow_unbound_tokenstotrue(orbind_tokenstofalseto turn binding off entirely); those tokens work for anyone holding the URL until they expire. - for
token_lifetimeminutes (720 by default). After that the grid shows an error until the page is reloaded.
Rotating APP_KEY also invalidates grids that are already open.
Testing a grid
$grid = Grid::fromQuery(Customer::query())->columns(['name'])->pagination(10); $this->actingAs($user) // tokens are bound to the user who renders them ->getJson(route('gridjs.data', ['grid' => $grid->token(), 'page' => 2])) ->assertOk() ->assertJsonPath('total', 25);
Configuration
php artisan vendor:publish --tag=gridjs-config
| Key | Default | |
|---|---|---|
prefix |
gridjs |
URL prefix of the data endpoint (GRIDJS_PREFIX) |
middleware |
['web'] |
Middleware on the data endpoint |
bind_tokens |
true |
Bind tokens to the user (or guest session) that rendered them |
allow_unbound_tokens |
false |
Issue unbound tokens when there's no user or session (else throw) |
guard |
null |
Auth guard used for binding (null = default) |
token_lifetime |
720 |
Minutes a rendered grid's token stays valid |
per_page |
10 |
Page size for pagination() without a number |
max_rows |
1000 |
Cap for grids without pagination |
theme |
mermaid |
Default theme, or null |
assets.inject |
true |
Print the script and theme before the first grid |
assets.cdn |
true |
jsDelivr (true) or public/vendor/gridjs (GRIDJS_CDN=false) |
Upgrading from dev-main
dev-main was never tagged. If you used it:
- The data endpoint now takes an encrypted
gridtoken instead ofmodelandcolumnsquery parameters. Nothing to change in your code; it was a security hole. - The
skeletontheme is gone (Grid.js 6 has onlymermaid); useclassName([...]). actions($buttons, $idIndex)now takes a field name:actions($buttons, key: 'id'). An integer still works, andbaseUrlbuttons still append/{id}.- Headings default to
Str::headline()of the field. Passnameto keep a raw heading.
Testing
composer test # Pest scripts/ci-local.sh # PHP 8.3/8.4/8.5 x Laravel 12/13, Pint, PHPStan, Pest
Need this built into your app, or something custom?
I build admin panels, reporting screens and Laravel packages for teams. If you'd rather have your data tables, filters and exports designed and wired up for you, or need something this package doesn't do, get in touch.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report privately via GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
Part of a family of open-source packages. See all of them.
| laravel-driver-js | Driver.js product tours and onboarding for Laravel |
| kiln | Deploy-time OPcache control for Laravel |
| laravel-tiptap | Tiptap editor for Laravel |
| laravel-logpulse | Log health monitoring and alerting |
Credits
- Grid.js by Afshin Mehrabani (MIT), which renders the table in the
browser. Its 6.2.0 build is bundled in
resources/distunder its own licence. This package is an independent wrapper and is not affiliated with Grid.js. - Amjad Iqbal
License
MIT. See LICENSE.md.
