yajra/laravel-datatables
Complete installer for Laravel DataTables core and plugins, offering seamless server-side DataTables integration for Laravel. Supports DataTables 2.x, Editor 2.x, Buttons, and Select extensions, simplifying dynamic AJAX-powered data grids.
Install the metapackage (or just the core).
composer require yajra/laravel-datatables:^13
This pulls laravel-datatables-oracle (the query layer) plus the Buttons, Select, Editor, and Fractal plugins. If you don't need Editor or exports, install yajra/laravel-datatables-oracle alone — the day-to-day API is identical, and you halve your upgrade surface when Laravel majors move.
Service providers are auto-discovered on Laravel 11+. Publish config and assets only when you need to change defaults:
php artisan vendor:publish --provider="Yajra\DataTables\DataTablesServiceProvider"
Where to look first.
config/datatables.php — engines, search.smart, search.case_insensitive, builder defaults. Read this before you write a single class; most "why doesn't search work" questions are answered here.vendor/yajra/laravel-datatables-oracle/src/Processors/ — the JSON envelope shape (draw, recordsTotal, recordsFiltered, data).yajrabox.com/docs/laravel-datatables, specifically the Eloquent engine and Column API pages.First use case: replace a hand-rolled admin index.
Scaffold a class, then let the controller render it:
php artisan datatables:make UsersDataTable
// app/DataTables/UsersDataTable.php
class UsersDataTable extends DataTable
{
public function query(User $model): QueryBuilder
{
return $model->newQuery()->with('roles')->select('users.*');
}
public function html(): HtmlBuilder
{
return $this->builder()
->setTableId('users-table')
->columns($this->getColumns())
->minifiedAjax()
->orderBy(0, 'asc');
}
protected function getColumns(): array
{
return [
Column::make('id')->width(60),
Column::make('name'),
Column::make('email'),
Column::computed('action')
->exportable(false)->printable(false)->width(80)->addClass('text-center'),
];
}
}
// controller
public function index(UsersDataTable $dataTable)
{
$this->authorize('viewAny', User::class);
return $dataTable->render('users.index');
}
{{ $dataTable->table() }}
@push('scripts')
{{ $dataTable->scripts() }}
@endpush
That is the whole loop: one class, one controller line, one Blade directive. Everything else in this package is refinement of those three pieces.
If you only need a quick endpoint and don't want a class, the inline form works:
Route::get('users/data', fn () => DataTables::eloquent(User::query())->toJson());
Pick the engine explicitly, and let of() auto-detect as a fallback.
DataTables::eloquent(User::query()) // Eloquent builder, supports with()/scopes
DataTables::query(DB::table('users')) // raw query builder
DataTables::collection($rows) // in-memory; no SQL, no pagination pushdown
DataTables::of($source) detects all three, but explicit is better: it documents intent and prevents a refactor from silently switching engines (which changes search/order semantics).
Scope authorization inside query(), not in the Blade template. The endpoint is a public JSON surface; every filter, sort, and search the client sends is attacker-influenced. The only place tenant/user isolation is guaranteed is the base query:
public function query(Invoice $model): QueryBuilder
{
return $model->newQuery()
->whereBelongsTo(auth()->user()->organization)
->select('invoices.*');
}
Use filterColumn for anything that isn't a plain column. Relation search and computed columns never work by accident:
DataTables::eloquent(User::with('roles'))
->filterColumn('roles.name', fn ($q, $kw) => $q->whereHas('roles', fn ($r) => $r->where('name', 'like', "%{$kw}%")))
->orderColumn('roles.name', fn ($q, $dir) => $q->orderBy(Role::select('name')->whereColumn('roles.id', 'role_user.role_id'), $dir))
->editColumn('created_at', fn (User $u) => $u->created_at->toFormattedDateString())
->addColumn('action', fn (User $u) => view('users.actions', compact('u'))->render())
->rawColumns(['action'])
->addIndexColumn();
Custom filters from request params. filter() runs once with access to the whole request; $globalSearch = true makes it participate in the search box instead:
->filter(function ($query) {
if ($status = request('status')) {
$query->where('status', $status);
}
}, true)
Keep a base DataTable class. After two screens you will have copy-pasted the same builder chain. Codify: table id convention, minifiedAjax(), responsive, default dom/layout, standard export buttons, standard date formatting, and a filename() implementation. New screens then only define query() and getColumns().
Buttons and export. Since v10.1 the export package ships by default:
->buttons([
Button::make('excel'), Button::make('csv'), Button::make('pdf'),
Button::make('print'), Button::make('reload'),
])
->parameters(['dom' => 'lfrtip']) // DataTables 2.x => use 'layout' instead
Exports respect exportable(false) on columns, so hide the action column rather than stripping it after the fact.
Editor for inline CRUD requires the separate yajra/laravel-datatables-editor package plus a commercial DataTables Editor license. Model it as a normal Laravel validation problem — rules live in the Editor config, not the JS.
Testing the endpoint. Drive it like a client, not like a unit test:
$this->actingAs($user)
->get(route('users.data', [
'draw' => 1, 'start' => 0, 'length' => 10,
'columns' => [...], 'order' => [['column' => 0, 'dir' => 'asc']],
]))
->assertOk()
->assertJsonStructure(['draw', 'recordsTotal', 'recordsFiltered', 'data'])
->assertJsonCount(10, 'data');
Add one test per screen asserting that a user from another tenant gets zero rows. That single assertion is worth more than any amount of UI testing.
Version lock-step is the real dependency risk. Package major == Laravel major (v13 ↔ Laravel 13). Every framework upgrade becomes a coordinated bump. Pin the version, name an owner, and add a line to your Laravel upgrade checklist. Choosing an older major to dodge an upgrade is legitimate but buys a shorter runway with a date attached.
Counts lie on groupBy / having / distinct queries. recordsTotal is derived from a count query that doesn't survive those constructs. Fix with ->skipTotalRecords() and set the values yourself, or restructure the query to avoid grouping in the base.
Column::make('relation.column') does not search or sort. It renders if the attribute exists, but ordering and searching silently do nothing. Always pair with filterColumn/orderColumn, or use editColumn on a real column plus addColumn for display.
rawColumns is mandatory for HTML. Action buttons and badge markup will render as escaped text otherwise. Conversely, never rawColumns a user-supplied field — that is a stored XSS vector.
Computed columns break ordering. A column with no backing SQL column will throw or produce nonsense when the user clicks the header. Mark it ->orderable(false) or supply orderColumn.
Global search behaves differently than you expect. config('datatables.search.smart') splits the keyword on spaces and ANDs the terms; case_insensitive toggles LOWER() wrapping. On Postgres, LIKE is case-sensitive — override with filterColumn and ILIKE, or set case_insensitive globally. Test search on every database you deploy to, not just your local MySQL.
"Invalid JSON response" in the browser is almost never this package. It is a PHP exception rendering an HTML error page. Open the network tab, look at the raw body, check storage/logs/laravel.log. Common causes: a missing rawColumns, an undefined relationship in a view, or a 403 from a policy.
Multiple tables on one page need distinct table IDs, distinct ajax routes, and their own @push('scripts') blocks. Two $dataTable->scripts() calls inside the same push will collide on the DataTables global config.
minifiedAjax() matters on wide tables. Without it, every column definition is serialized into the ajax URL as a query string, which blows past URL length limits on screens with 20+ columns.
Watch the query cost, not the response size. The package runs three queries per request (count, filtered count, paginated rows). On a large table with an expensive whereHas, the counts dominate. Measure with Laravel Debugbar or DB::listen before shipping, and consider ->setTotalRecords() with a cached count for very large, rarely-changing tables.
Exports are memory-bound. Button::make('excel') builds the whole file in PHP memory. Above ~20k rows, move exports to a queued job using chunk() and a streamed writer; the DataTables export button is fine for a few thousand rows and a liability beyond that.
Prefer the core package if you use only the grid. The metapackage's value is Editor, Buttons, Select, and Fractal. If you have a design system and no inline editing, take laravel-datatables-oracle alone and keep the JSON contract — same developer experience, materially less to maintain.
Debugging tip: drop the frontend. Hit the ajax route directly with a minimal payload (?draw=1&start=0&length=10) and read the JSON. Half of all reported issues are frontend config (DataTables 2.x uses layout where 1.x used dom; Buttons and Select options moved), not server-side. Isolating that first saves an hour every time.
How can I help you explore Laravel packages today?