arm092/livewire-datatables
Livewire Datatables builds searchable, sortable, filterable, and editable admin tables with Laravel, Livewire, and Tailwind CSS. Query with models or the query builder, format columns, add bulk actions, and use the built-in Apricode theme.
Install and confirm the runtime matrix first. The package requires Laravel 10–13, Livewire 4.3.5+, Tailwind and Alpine. If your app is still on Livewire 3, stop here — this is not a drop-in. Check composer show livewire/livewire before you commit to anything.
composer require arm092/livewire-datatables
Add the Alpine cloak rule to your app CSS, or every filter dropdown and column-visibility menu will flash on load:
[x-cloak] { display: none; }
Decide on publishing up front. You do not need to publish. The default Apricode theme is self-contained, needs no tailwind.config.js entries, and is scoped to the package's own controls so it won't bleed into your markup. Publish only if you have a concrete reason:
php artisan vendor:publish --provider="Arm092\LivewireDatatables\LivewireDatatablesServiceProvider"
Publishing gets you resources/livewire/datatables/* (for restyling) and a config file with the default date/time formats. Reasons to publish: the design must match an existing admin shell, you want to override the tooltip/link partials, or you need different global date formats. Otherwise leave it alone — published views become a merge burden on every upgrade.
Where to look first. Three places, in this order:
config/livewire-datatables.php in the package (or published) — date/time formats.resources/views/livewire/datatables/ in the package — the actual partials behind linkTo, truncate, view, and column rendering. When output looks wrong, read these.Column class source — the README's method table is good but the source is authoritative on defaults and chaining.First use case — a throwaway read-only list. The fastest thing to do is inline the component in Blade. Use this to prove the install works before writing any class:
<livewire:datatable
model="App\Models\User"
name="all-users"
include="id, name, email, created_at"
dates="created_at"
/>
Load the page. If you get a working table with global search and sortable headers, the install is sound and you can move on to a real component.
Then generate the component you'll actually keep. The inline form is a demo tool; anything with filters, callbacks, or editing belongs in a class, because props-in-Blade doesn't scale past a handful of columns.
php artisan make:livewire-datatable users-table --model=user
# -> app/Livewire/Datatables/UsersTable.php with public $model = User::class
php artisan make:livewire-datatable reports.activity
# -> app/Livewire/Datatables/Reports/Activity.php
The class extends Arm092\LivewireDatatables\Livewire\LivewireDatatable (note the Livewire\ sub-namespace in the import — the README's import line is the one to copy verbatim, it's easy to get wrong) and returns columns from getColumns().
builder() over $modelpublic $model is the convenience path. The moment you have a tenancy scope, a soft-delete policy, an active flag, or a join you always want, switch to builder():
public function builder(): Builder
{
return User::query()
->where('users.tenant_id', auth()->user()->tenant_id)
->with('planet'); // eager-load whatever callbacks touch
}
Two reasons this matters more than it looks. First, Column::delete() resolves records through builder(), so a scoped builder is also your authorisation boundary for the delete button — a model policy is applied automatically, but only to rows your builder can reach. Second, the package adds its own joins for dot-notation relation columns; starting from a pre-constrained builder keeps that join logic anchored to the right base query. If you start from $model and then need a scope, you'll end up fighting the component instead of writing one line of where.
Also call ->with() for any relation a callback column dereferences. Without it you get per-row lazy loads and an N+1 that only shows up on the second page of results.
This is the single biggest productivity lever in the package. The type decides default formatting and what the filter UI becomes; the fluent methods only decorate on top.
| Type | Filter becomes | Use when |
|---|---|---|
Column |
text input | free-text fields |
NumberColumn |
numeric range | IDs, money, counts |
BooleanColumn |
yes/no | flags, email_verified_at-style null checks |
DateColumn / TimeColumn |
date / time range | any date-ish column; also handles formatting |
LabelColumn |
none | fixed header + fixed content, no SQL executed |
Column::index($this) |
none | row numbering independent of data |
Column::checkbox() |
none | bulk selection |
Column::delete() |
none | per-row delete with policy check |
If you pick the wrong base type you end up writing a callback to fake formatting that DateColumn::format() would have given you for free. A date stored as a string and rendered with Column::name('dob') will show raw ISO timestamps and filter as free text.
filterOn pairColumn::callback() is where this package earns its keep — you get full PHP for display without hand-writing table markup. But a derived display value has no SQL column to filter on, so you must tell it:
Column::callback(['name', 'allegiance'], function ($name, $allegiance) {
return "{$name} is allied to {$allegiance}";
})
->label('Allegiance')
->filterable(['Rebellion', 'Empire'])
->filterOn('users.allegiance'),
The rule: filterOn() takes raw SQL, not Eloquent dot notation. users.allegiance, never user.allegiance. Passing the Eloquent form silently produces wrong results or a SQL error. filterOn() also accepts an array, which the package combines with OR — useful when a display string is derived from two underlying columns.
For custom rendering that callbacks can't express cleanly, ->view('your.view') hands the column value, the whole row, and extra parameters to a Blade template. That's the escape hatch for badges, avatars, status pills — far better than concatenating HTML in a closure.
Column::name('planet.name')->label('Planet')->searchable(),
The package walks the relationship and adds the joins for you. For HasOne/BelongsTo this is clean. For HasMany/BelongsToMany/HasManyThrough it builds an aggregated subquery — correct, but it's a correlated subquery per row on a joined table. On a large table, sort or search on one of these and watch the query time. Mitigation: keep many-relation columns out of the default sort, mark them ->unsortable() if the sort is genuinely expensive, and test the generated SQL against production-shaped data. Grab the query with DB::listen() in a service provider during development rather than guessing.
Column::name('email')
->editable()
->rules(['required', 'email']),
rules() accepts an array, a string, or a closure — the closure form receives the current model, the field name, and the submitted value, which is the right place for "unique except this row" and "only editable by an admin" checks:
->rules(fn ($model, $field, $value) =>
Rule::unique('users', 'email')->ignore($model->id))
Because editing writes straight to the model, anything the model doesn't guard against (mass-assignment, observers that assume web-form input) is now reachable from the table. Verify $fillable and any model events before shipping an editable column on a sensitive field.
Column::checkbox() puts IDs into the public $selected array; Column::checkbox('some_uuid') if your key isn't id. From there you write the action yourself against $this->selected (validate the selection, re-query through builder() rather than trusting the IDs, then $this->dispatch('refreshLivewireDatatable') or reset the selection). The README advertises "Mass Action (Bulk) Support" without giving the API surface, so read the component source for whatever built-in helper exists before hand-rolling one — but do not trust submitted IDs. Re-resolve them through builder(), exactly as Column::delete() does.
The component listens for refreshLivewireDatatable. Any sibling component that mutates the underlying data should fire it:
$this->dispatch('refreshLivewireDatatable')->to(UsersTable::class);
This is the glue that makes a drawer/modal edit form and the table coexist. Wire it up on day one; retrofitting it after three components depend on stale state is annoying.
For anything over ~8 columns, lean on ->group('identity') plus ->hideable() and ->hide(). Groups toggle as a unit, so a "personal details" group can be collapsed for a compact default view without you building a settings panel. Set ->hide() on the low-value columns (long text, timestamps, internal IDs) and let power users turn them on.
->exportCallback(fn ($value) => ...) reformats a cell for export; ->excludeFromExport() drops a column entirely. Set both at column-definition time, not at export time — the export path reuses the column array, so a column with a display callback but no exportCallback will export the mutated display string (with HTML, or the linkTo markup) rather than the raw value. This is the most common "why is my CSV full of HTML" bug.
The name prop is not optional in practice. Multiple tables on one page — or the same table class on two different pages — will collide without a unique name. Treat it like a route name: one per table, never reused. Symptom is cross-talk in filters, sorting, and column visibility that looks like a caching bug but isn't.
Read the source for the column method name. The README shows both "declare a public method columns" and code using getColumns(). The prose and the example disagree; the class in vendor/ is the tiebreaker. Same for hide vs hidden in the method table (->hide() in the prose, ->hidden() in one example) — grep the Column class before you spend twenty minutes on a method-not-found.
raw() requires an explicit alias. Column::raw("CONCAT(...) AS \Native Age`")— theAS aliasis mandatory, and the alias becomes what you reference infilterOn()` and sorting. Omit it and you get a confusingly-timed SQL error far from the column definition.
Livewire 4 root-element rule breaks published views. If you published views under an older version, datatable.blade.php and hide-column-multiselect.blade.php may have multiple root elements. After upgrading from 3.2.1 or earlier, refresh exactly those two files. Do not run vendor:publish --force — it will overwrite your customised views along with them. Diff and copy.
Tailwind purge will eat package classes if you have an aggressive content config. The default theme is designed to avoid this, but if you've narrowed content to resources/views/**, published views live in a path you may not be scanning. Add the package views to your content globs, or accept that you'll be publishing and inlining.
Column::delete() needs a policy or it's a footgun. It applies an existing model policy automatically — but "automatically" means "if one exists." Verify with a user who shouldn't be able to delete, not just an admin who can.
Date/time formats are global, not per column. They come from config. If one table needs Y-m-d and another needs d/m/Y H:i, pass the format explicitly to ->format('...') rather than publishing config and changing the default for the whole app.
link('/users/{{slug}}/edit', 'edit {{first_name}}') does string interpolation of row attributes. That's convenient and it is also an injection surface if any interpolated attribute is user-controlled and lands in an href. Keep interpolation to IDs and slugs; if you need arbitrary user content in a URL, drop to ->view() and use url() / proper escaping.
Debugging checklist, in the order that resolves issues fastest:
dump($this->getColumns()) in the component — is it in the array, and is it ->hide()n?filterOn() for Eloquent dot notation, and check the filterable option values match the stored values exactly (case, whitespace).sortBy() pointing at a real column or expression.DB::listen() and look for correlated subqueries from many-relation columns.[x-cloak] is in your compiled CSS, and confirm Alpine is loaded once.Extension points, ranked by cost of maintenance: ->view() on a single column (cheapest, zero upgrade risk) → ->exportCallback() / custom Column subclass → published config → published views (most expensive; every upstream template change becomes a manual merge).
Assess the risk honestly. 30 stars, zero dependents, and a maintained-fork lineage means you are, functionally, adopting a dependency solo. Pin the exact version in composer.json, read the changelog on every bump, and keep your table components thin — column arrays and builder() only, no business logic buried in closures. If the package stalls, thin components can be rewritten against raw Livewire in an afternoon; components with logic smeared through twenty callbacks cannot.
How can I help you explore Laravel packages today?