filament/tables
Filament Tables is a powerful, customizable data table builder for Laravel admin panels, offering sorting, filtering, search, pagination, actions, bulk actions, and reusable columns for fast, flexible interfaces.
Install (standalone, outside a panel)
composer require filament/tables -W
php artisan filament:install --tables # wires Tailwind + publishes the config
php artisan make:livewire ListOrders
Add @filamentStyles in <head> and @filamentScripts before </body> (plus Livewire's own directives if you aren't already using them). On Tailwind v4, the CSS wiring is @source / @import directives in resources/css/app.css rather than tailwind.config.js content paths — grab the exact snippet from the standalone-tables docs for your major version, then npm run build.
The one shape you need to memorise
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class ListOrders extends Component implements HasTable
{
use InteractsWithTable;
public function table(Table $table): Table
{
return $table
->query(Order::query()->with('customer'))
->columns([
TextColumn::make('number')->searchable()->sortable(),
TextColumn::make('customer.name')->searchable()->sortable(),
TextColumn::make('status')->badge(),
TextColumn::make('total')->money('usd')
->summarize(Sum::make()),
])
->defaultSort('created_at', 'desc');
}
public function render(): View
{
return view('livewire.list-orders');
}
}
<div>{{ $this->table }}</div>
That's the whole contract: HasTable + InteractsWithTable + a table() method + {{ $this->table }}. If you're inside a Filament panel already, you get this for free via Resources; filament/tables standalone is for dashboards, widgets, and Livewire pages that aren't panel resources.
Where to look first
vendor/filament/tables/src/Columns/ — every column class and its fluent methods; this is the real API surface.vendor/filament/tables/src/Filters/ and src/Actions/ — same reasoning.vendor/filament/tables/resources/views/ when you need to know why markup looks the way it does (publish only if you must).Filament\Actions\* and renamed ->actions() / ->bulkActions() to ->recordActions() / ->toolbarActions().First use case worth shipping: a read-only list with sorting, a search box, one SelectFilter, and a row Action::make('view')->url(...). It takes under an hour and proves the Livewire/Tailwind plumbing end to end before you commit to migrating CRUD screens.
Extract table definitions into reusable classes. The highest-leverage habit with this package: put the definition in a static configure(Table $table): Table class and call it from both a panel Resource and a dashboard widget. One definition, two surfaces, no drift.
final class OrdersTable
{
public static function configure(Table $table): Table
{
return $table->columns([...])->filters([...]);
}
}
Query vs. relationship. Use ->query(Model::query()) for pages and ->relationship() inside relation managers. Anything you touch in a column must be eager loaded — ->query(Order::query()->with(['customer', 'items'])) or ->modifyQueryUsing(fn (Builder $q) => $q->withCount('items')). Filament will happily issue N+1 queries for customer.name otherwise.
Column cheat-sheet for daily work
->formatStateUsing(), ->description(), ->badge()->color(fn ($state) => ...), ->icon(), ->limit(40)->tooltip(fn ($state) => $state), ->wrap(), ->copyable().->state(fn (Order $record) => $record->totalWithTax()), or a getStateUsing() closure.TextInputColumn, ToggleColumn, SelectColumn, CheckboxColumn — each needs ->rules()/->disabled() and ->updateStateUsing() if you don't want the default update() call.->toggleable(isToggledHiddenByDefault: true), ->visible(fn () => auth()->user()->isAdmin()).->summarize(Sum::make()->label('Total')) on numeric columns.Filters that earn their keep
SelectFilter::make('status')->options(OrderStatus::class)->multiple(),
Filter::make('created_at')
->schema([DatePicker::make('from'), DatePicker::make('until')])
->query(fn (Builder $q, array $data) => $q
->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d))),
Use ->deferFilters() when the form has several inputs — it adds an "Apply" button and stops a Livewire round-trip per keystroke. Add ->persistFiltersInSession() (same for search/sort) so state survives navigation.
Actions and bulk actions
->recordActions([
Action::make('refund')
->requiresConfirmation()
->schema([Textarea::make('reason')->required()])
->action(fn (Order $record, array $data) => $record->refund($data['reason']))
->visible(fn (Order $record) => $record->isRefundable()),
])
->toolbarActions([
BulkAction::make('export')
->deselectRecordsAfterCompletion()
->action(fn (Collection $records) => /* streamDownload CSV */),
])
Closures receive typed injection: the record, the form data array, the Livewire component, $livewire, $table, $selectedRecords. For anything over a few hundred rows, dispatch a queued job from the bulk action instead of looping inline, and check ->chunkSelectedRecords() semantics so you aren't loading 50k models into memory.
Live-updating dashboards. ->poll('10s'), ->deferLoading() for below-the-fold tables, ->striped(), ->paginated([10, 25, 50]), ->defaultPaginationPageOption(25). Pair poll() with a cheap query — polling a table that sorts unindexed columns is the fastest way to melt a small database.
Testing pattern you should adopt immediately
livewire(ListOrders::class)
->assertCanSeeTableRecords($orders)
->searchTable('ACME')
->filterTable('status', 'paid')
->sortTable('total', 'desc')
->callTableAction('refund', $order, data: ['reason' => 'duplicate']);
These helpers exist because table internals are stateful and fiddly; don't try to assert against rendered HTML when a purpose-built helper is available. On v4 the action helpers moved to callAction(TestAction::make('refund')->table($record)).
Migration path from hand-rolled Blade tables: convert read-only lists first (lowest blast radius), then CRUD listings, then bulk operations. Each conversion is usually a 60–80% line-count reduction versus a manual loop + search + pagination implementation.
A table with no ->query() or ->relationship() renders empty with no error. This is the #1 first-hour confusion. Empty table? Check the query before you check the Livewire component.
Forgetting the HasTable interface (while remembering the trait) produces confusing errors around $this->table being undefined. Both are required; the interface is what the Blade call resolves against.
formatStateUsing() is cosmetic. It does not affect sorting or search — those hit the database. If a column is computed, either persist it, or give sorting/search an explicit target:
TextColumn::make('full_name')
->state(fn ($record) => "{$record->first} {$record->last}")
->sortable(query: fn ($q, $dir) => $q->orderBy('last', $dir))
->searchable(query: fn ($q, $term) => $q->where('first', 'like', "%{$term}%"))
Searching across relationships is not automatic. TextColumn::make('customer.name')->searchable() searches the relation's title column via the relationship's searchable attributes; if you need custom behaviour, supply ->searchable(query: ...) with a whereHas and confirm the generated SQL. This is the most common source of "search returns nothing" reports.
Table state lives in the query string. Multi-filter admin screens produce enormous URLs, and stale ?tableFilters[...] links bite when bookmarked. Use ->queryStringIdentifier('orders') when two tables share a page, and ->persistFiltersInSession() if you'd rather keep state out of the URL. If the surrounding component also uses #[Url] properties, watch for collisions.
Actions on pages inside another modal (table in a modal, action opens a modal) can stack z-index/backdrop oddly. Prefer ->modalWidth() + closeModalByClickingAway(false), or move the edit action to a redirect/page action rather than nesting.
Authorization is not free. Filament consults policies for built-in actions (view, update, delete), but custom Action::make('refund') closures run if the button is visible. Gate with ->visible(fn ($record) => auth()->user()->can('refund', $record)) and assert authorization again inside the ->action() — the modal form is client-initiated and the visibility check isn't a security boundary.
Bulk actions with pagination and selection. By default selection persists across pages (good), but users are surprised by it. Use ->selectCurrentPageOnly() or ->checkIfRecordIsSelectableUsing() when partial selection would be dangerous. Also verify your model has a sane primary key — non-Eloquent ->records() arrays need stable unique keys or selection and actions misbehave.
Styling breakage is almost always Tailwind wiring. If the table renders unstyled, you're missing the Filament preset/@source paths or you haven't rebuilt assets. In a non-Filament app, confirm @filamentStyles is present and that your build includes vendor/filament/**/*.blade.php. Dark mode requires the dark class strategy to match what your layout uses.
Performance checklist for large datasets: keep paginated() on (never ->paginated(false) past a few hundred rows), index every sortable() and searchable() column, eager load everything referenced by a column, use ->deferLoading() and ->poll() sparingly, and push heavy export work to a queue. Above ~50k rows with complex filtering, consider a dedicated data grid (AG Grid, Tabulator) rather than fighting the abstraction.
Version drift is the sharpest edge. Filament ships majors quickly and most tutorials, blog posts, and LLM completions are v3-shaped. Before pasting any snippet, check the import namespace: v4 moved actions to Filament\Actions\* and renamed actions()/bulkActions() to recordActions()/toolbarActions(). Read the upgrade guide and diff your own table classes — the renames are mechanical but silent if you rely on deprecated aliases.
Extension points worth knowing: custom columns (extend Column, override view()), custom filters (extend BaseFilter, implement apply()/reset()), and view overrides published per-component via php artisan vendor:publish --tag=filament-tables-views. Reach for a custom column rather than raw HtmlString in formatStateUsing() — you keep escaping, sorting, and export behaviour intact.
No built-in CSV/Excel export. The idiomatic route is a BulkAction calling response()->streamDownload() on a chunked query, or the community pxlrbt/filament-excel package. Budget for it explicitly — stakeholders assume export exists because the table "looks like a spreadsheet."
How can I help you explore Laravel packages today?