yajra/laravel-datatables-html
Generate DataTables scripts in PHP with Laravel DataTables Html. Build tables using a fluent builder, with support for Vite, DataTables 2.x, and Laravel 12. Install via composer require yajra/laravel-datatables-html:^13.
This package is the rendering half of the Yajra DataTables stack. It does not fetch or filter data — yajra/laravel-datatables-oracle does that. This one turns a fluent PHP Builder into the <table> markup plus the jQuery DataTable({...}) init block. You will almost always install both together, and you should treat them as one dependency:
composer require yajra/laravel-datatables-oracle yajra/laravel-datatables-html
php artisan vendor:publish --tag=datatables-html # optional, but do it once to see the config
Then, before writing any table, decide how your JS is loaded. If you use Vite (you probably do), tell the builder once in AppServiceProvider::boot():
use Yajra\DataTables\Html\Builder;
public function boot(): void
{
Builder::useVite(); // emits <script type="module"> instead of a legacy inline script
}
Scaffold your first table rather than hand-writing the class — the generator gets the boilerplate right:
php artisan datatables:make Users
That gives you app/DataTables/UsersDataTable.php. The html() method is where this package lives:
public function html(): HtmlBuilder
{
return $this->builder()
->setTableId('users-table')
->columns($this->getColumns())
->minifiedAjax()
->orderBy(0)
->pageLength(25);
}
protected function getColumns(): array
{
return [
Column::make('id')->width(60),
Column::make('name'),
Column::make('email'),
Column::make('created_at')->searchable(false),
Column::computed('action')->title('')->width(120)->orderable(false)->searchable(false),
];
}
Controller and view:
public function index(UsersDataTable $dataTable)
{
return $dataTable->render('users.index');
}
{{-- resources/views/users/index.blade.php --}}
{!! $dataTable->table(['class' => 'table table-striped w-100']) !!}
{!! $dataTable->scripts() !!}
Where to look first, in order: config/datatables-html.php (default classes, script template), the html() method of a generated DataTable class, then Yajra\DataTables\Html\Builder's method list — it is a near 1:1 mirror of the DataTables JS options object, so the DataTables.net reference is your real documentation. Column, Button, Editor, and Field are the four supporting builders you'll touch daily.
The first genuinely useful use case is the "admin index page" — any listing that needs server-side pagination, a global search box, per-column sort, and a couple of export buttons. That is exactly the sweet spot; don't try to use this for a marketing-site table with 20 static rows (just write the HTML).
Keep html() and data() in lockstep. The single most common pattern is a DataTable class with two halves: html() declares columns for the client, data() (oracle) declares columns for the server. They are ordered arrays and must align. Most teams extract the shared list into a protected function getColumns(): array used by both, or name columns in the same order in both methods and review it in PRs.
Computed/action columns. For edit/delete buttons, use Column::computed('action') on the HTML side and ->addColumn('action', fn ($row) => ...) + ->rawColumns(['action']) on the query side. A very common day-to-day variant is rendering the buttons in Blade instead of concatenating HTML strings:
->addColumn('action', fn (User $user) => view('users.actions', compact('user'))->render())
->rawColumns(['action'])
Ajax wiring. ->minifiedAjax() is the default you want: it minimizes requests and appends the CSRF token for POST-style setups. For explicit control use ->ajax(route('users.data')), and for POST-based tables (long filter payloads, avoid URL length limits) use ->ajaxWithCsrf() or ->postAjax(). If your data endpoint sits behind a policy, gate it there too — the HTML builder's ajax() URL is visible to anyone who can load the page.
Layout, not DOM hacking. Since v11.1 you get DataTables 2's layout API instead of the old dom string soup. Day-to-day this means:
->layout([
'topStart' => 'pageLength',
'topEnd' => 'search',
'bottomStart' => 'info',
'bottomEnd' => 'paging',
])
and, usefully, layout entries can be views or Livewire components (v11.3), which is how you inject a custom filter bar or a "Create" button without touching raw JS.
Authorization-aware script fragments. v12.2 added addScriptIf, addScriptIfCannot, and addScriptIfCan. This is the pattern for "only admins see the bulk-delete button" without branching your whole view:
return $this->builder()
->columns($this->getColumns())
->addScriptIfCan('manage-users', 'table.button().add(0, { text: "Delete" });')
Buttons and Editor. Button::make('export'), Button::make('print'), Button::make('colvis') cover 90% of needs; the rest is ->action('function(e, dt, node, config){...}') with a JS string. Editor is where this plugin earns its keep on CRUD screens: Editor::make()->fields([Field::make('name')->required(), Field::make('email')]) keeps the modal form definition in PHP next to the columns. Editor refresh (->editor(...)->refresh(), v12.2) is what you want after inline edits.
Reuse rather than repetition. Three escalating options, in order of preference:
DataTable class or trait that returns your standard builder (->processing()->serverSide()->responsive()->lengthMenu(...)), so each concrete table only declares columns.Builder, Column, Button, Editor, and Field are all macroable (v11.5–v11.6). Register Column::macro('money', fn () => $this->render('...')->className('text-end')) in a service provider.config/datatables-html.php for class names and the script template, if you need to swap the generated template wholesale.Livewire integration. The supported pattern is wire:ignore around the table container plus re-initialization on component refresh; Livewire 4 support landed in v12.7. If you're on Livewire 3 SPA navigation, verify the table re-inits on livewire:navigated — this is the one place where the generated DOMContentLoaded bootstrap will not fire twice for you.
Columns must match, exactly and in order. A mismatch between html()->columns() and the query's column set produces blank cells or silently shifted data, not an exception. When a table "shows the wrong data one column over," check this first.
The bootstrap changed to DOMContentLoaded. Since v12.0.2 the generated script does not rely on jQuery's $(function(){}). If you load the table's scripts before your DataTables JS bundle, or you re-render a partial containing scripts(), initialization will not run. With Vite, the generated type="module" block is deferred, which is normally what you want — but only if your Vite entry actually imports jQuery DataTables.
Table ID collisions. setTableId() (or the generated default derived from the class) is the global key in window.LaravelDataTables. Two tables on one page with the same ID will clobber each other. On dashboards with several tables, set explicit distinct IDs, and make sure the id attribute on $dataTable->table() agrees with the builder.
searchable() on relations needs a real column name. Column::make('user.name') maps to the JS data key, but the server's search/sort needs ->name('user.name') and a join or subquery that resolves it. Without that, sorting "works" in the UI and does nothing in the query.
visible(false) is not authorization. v11.5.1 fixed a bug where unauthorized columns leaked through the builder, but the durable pattern is still: decide column visibility server-side (filter the array before passing it to columns()), not with ->visible(). A hidden column's data still ships in the JSON payload.
Quoting in render() and action() strings. These are raw JS strings embedded in PHP. Prefer single-quoted PHP strings around function (data) { return ... }, and avoid interpolating user data into them. If the JS gets long, use ->render('js/columns/money') — the package accepts a view name and even a data array (v12.2) and inlines the rendered template.
Debug by reading the generated script, not the PHP. Builder exposes generateScript(); you can also just view-source. Ninety percent of "the options aren't applying" bugs are a typo'd option name (the builder passes through anything) or an option being overridden later in the chain. Option merges are last-wins, and ->parameters([...]) is the escape hatch for any DataTables option the fluent API hasn't wrapped.
Version pinning is strict but documented. Laravel 8 → ^4, 9 → ^9, 10 → ^10, 11 → ^11, 12 → ^12, 13 → ^13. Pin the major to your Laravel release and bump together; do not float ^13 across a Laravel 11 upgrade.
Config caching gotcha. If you change config/datatables-html.php (template, class names) and run config:cache in deploy, remember the generated script template lives in that config. Publishing assets is optional, but publishing the config once makes template and class overrides discoverable instead of magic.
Asset/JS load order with Vite. Builder::useVite() only changes the script tag type — it does not bundle DataTables for you. Your Vite entry must import jQuery, datatables.net, and whichever extensions (Buttons, Responsive, Select, Editor) you configure. Forgetting an extension in the bundle yields a runtime "unknown option"/"not a function" in the browser console, not a PHP error. That console message is the signal; check the extension list against your ->buttons()/->responsive()/->select() calls.
How can I help you explore Laravel packages today?