nodus-it/livewire-datatables
Build interactive, reactive data tables in Laravel with Livewire. Add sorting, searching, filtering, and pagination using simple PHP components—no custom JavaScript required. Clean Blade templates make every table easy to customize.
Confirm the stack before you install. This package is Laravel + Livewire + Bootstrap 4. If your layout is Tailwind or Bootstrap 5, you are signing up to re-style the published Blade views or accept visual inconsistency — check resources/views in the vendor directory first. Also verify the Livewire constraint in composer.json of the package: 0.10.x was built against Livewire 2; Livewire 3 changed lifecycle hooks and component registration in ways that break table packages. Do not assume compatibility — read the constraint and the CI matrix.
composer require nodus-it/livewire-datatables:0.10.3 # exact pin, not ^0.10
php artisan vendor:publish --provider="Nodus\LivewireDatatables\LivewireDatatablesServiceProvider"
Publish config and views even if you plan no changes — having the views in your repo is what makes restyling and debugging possible, and it gives you a diff target when you upgrade.
Where to look first, in this order:
vendor/nodus-it/livewire-datatables/README.md — this is the documentation. There is no docs site, and the GitHub release notes are a bare compare link.vendor/nodus-it/livewire-datatables/src/ — find the base table component class and the Column class. These are your real API surface. Read the method signatures rather than guessing from the README.vendor/nodus-it/livewire-datatables/resources/views/ — tells you exactly what markup and CSS classes you are inheriting.tests/ — the only executable specification of behaviour, especially sorting and search semantics.First use case: one read-only screen. Pick an audit log, a user list, or an orders index — something with server-side pagination needs, no write actions, and no authorization subtleties. The shape you are building toward:
// app/Livewire/Tables/AuditLogTable.php
class AuditLogTable extends DataTableComponent
{
public function getColumns(): array
{
return [
Column::make('id', 'ID')->sortable(),
Column::make('event', 'Event')->searchable(),
Column::make('user.name', 'User')->searchable(), // eager-load it
Column::make('created_at', 'When')->sortable(),
];
}
public function getBuilder(): Builder
{
return AuditLog::query()->with('user'); // no relation without with()
}
}
Confirm the exact base class name and abstract method signatures against src/ — that is a two-minute check that saves an hour of blind guessing.
Do not roll this out to write-action screens until the pilot renders correctly at realistic row counts and you have profiled the query log.
One table class per screen, and a base class above them all. Create an abstract AppTable extends DataTableComponent that sets your house defaults — page size, default sort, empty-state copy, date formatting helper. Every screen table extends that, not the package class. This is the single highest-leverage decision in the whole adoption: when a minor bump breaks something, you edit one file instead of twenty.
abstract class AppTable extends DataTableComponent
{
public int $perPage = 25;
public function getPerPageOptions(): array { return [10, 25, 50, 100]; }
}
Extract the query into its own method, and let exports reuse it. The moment you add CSV export, scheduled digests, or a dashboard count, you will need the same filtered query outside the Livewire lifecycle. Put it in a named method (or a dedicated query object) and have both the table and the export call it:
public static function baseQuery(User $actor): Builder
{
return AuditLog::query()
->where('tenant_id', $actor->tenant_id) // scoping lives HERE
->with('user');
}
public function getBuilder(): Builder { return static::baseQuery(auth()->user()); }
Scope authorization in the query, never in the renderer. If a row appears in the data source, it is exposed — hiding the edit button in Blade does not hide the row. And for row actions specifically, re-check Gate::authorize() inside the action method itself; a user can call a Livewire action directly without the button ever rendering.
Custom cells: Blade partial over a closure. Closures do not survive Livewire hydration inside public properties, and they make the table class unreadable past four columns. Use a small view per cell type:
Column::make('status', 'Status')->view('tables.cells.status-badge')
{{-- resources/views/tables/cells/status-badge.blade.php --}}
<span class="badge badge-{{ $row->status === 'active' ? 'success' : 'secondary' }}">
{{ ucfirst($row->status) }}
</span>
Filters as public properties, with debounce. Search and filter state belongs on the component as public properties bound with wire:model, so Livewire serialises and replays it correctly. Add a debounce to free-text search — wire:model.debounce.500ms on Livewire 2 — because each keystroke is otherwise a full server round-trip.
Compose rather than centralise. Use the table as a child Livewire component inside the page, and emit events up for anything the page owns (opening a modal, redirecting to edit). A table component that also handles creation forms, bulk edit, and settings becomes the thing you can never safely change.
Row actions should navigate, not mutate in place. Link to edit routes and route destructive actions through a controller or a dedicated action method with confirmation. Do not stretch this into a spreadsheet — bulk cell editing is explicitly outside its design envelope.
0.x semver means minor bumps can break you. 0.10 → 0.11 is permitted to be a breaking change. Pin 0.10.3 exactly in composer.json, and add a CI check that fails if the resolved Livewire or Laravel version moves outside the range you tested.git log on the tag range plus a diff of src/ and resources/views/ takes ten minutes; upgrading blind costs a day.with(). Verify with Debugbar or DB::listen() on the heaviest screen before it ships, not after.user.name as a display column is fine; making it sortable() generally requires a join or a subquery orderBy. If a sort silently does nothing or errors, that is the cause.whereHas (correct) or a join (fast, but watch duplicate rows). Pick deliberately per screen.wire:model vs wire:model.live — Livewire 2 and 3 use different syntax and deferral semantics. If you have mixed versions across components, this will bite on filters specifically.AppTable, swapping to Filament tables or an in-house implementation later is a bounded refactor. The thing that makes adoption expensive is not the package — it is letting Column::make(...) calls scatter across thirty components.1.0 for anything with an SLA or a long support horizon.How can I help you explore Laravel packages today?