Table
Advanced Features
Polling, performance and debugging: what a table costs per render, and the switches that change it.
On this page
- Table of Contents
- Sub-Rows (Expandable Rows)
- Basic Sub-Rows
- Expansion Baseline
- Sub-Row Relation with Eager Loading
- Independent Sub-Row Filtering
- Custom Sub-Row View
- Sub-Row Livewire State
- Sub-Rows API
- Summary Footer (Aggregates)
- Column-Level Summary
- Table-Level Summary
- Summary Scopes
- Custom Summary Formatting
- How It Works
- Summary API
- Polling (Auto-Refresh)
- Table-Level Polling
- Keep Alive (Background Tabs)
- Only Visible (Viewport)
- Conditional Polling
- Custom Poll Method
- Change Detection (Skip Unchanged Renders)
- Live Tables (Multi-User)
- Pushing instead of waiting — broadcast: true
- Row/Column Polling
- Polling API
- Lazy Loading
- Custom Placeholder
- How It Works
- When to Use
- Performance Optimization
- Simple Pagination
- Cursor Pagination
- Row Partials
- Query Caching
- Chunked Bulk Processing
- Performance Comparison
- Query Debugging
- QueryPlan Inspection
- Raw SQL
- Column Metadata
- SQL Debug
- Development Usage
- Layout
- A List Instead of a Table
- Stacked on Mobile
- Header Actions on a Phone
- The Card's Anatomy
- Sub-Rows on a Card
- Totals on a Card
- Column Breakpoints
- Per-Record Mobile Display
- Column Toggling
- Remember each user's layout
- Saved Views
- What a view carries
- In the UI
- Storage and sharing
- Saved views API
- Row Context Menu
- Notifications Per-Table
- URL State Persistence
- Multiple Tables Per Page
- Notes
- Browser Testing Selectors
- Custom Views
- Custom Table View
- HasView Trait
- Complete Real-World Example
Table of Contents
- Sub-Rows (Expandable Rows)
- Summary Footer (Aggregates)
- Polling (Auto-Refresh)
- Lazy Loading
- Performance Optimization
- Query Debugging
- SQL Debug
- Layout
- Column Toggling
- Saved Views
- Row Context Menu
- Notifications Per-Table
- URL State Persistence
- Browser Testing Selectors
- Custom Views
Sub-Rows (Expandable Rows)
The HasSubRows trait enables expandable child rows for hierarchical data — orders → items, categories → products, departments → employees.
Basic Sub-Rows
use NyonCode\WireTable\Table;use NyonCode\WireTable\Columns\TextColumn; $table ->model(Order::class) ->columns([ TextColumn::make('number')->searchable()->sortable(), TextColumn::make('customer.name')->searchable(), TextColumn::make('total')->money('CZK')->sortable(), BadgeColumn::make('status')->colors([...]), ]) ->subRows('items') ->subRowColumns([ TextColumn::make('product.name'), TextColumn::make('quantity')->alignRight(), TextColumn::make('unit_price')->money('CZK'), TextColumn::make('subtotal')->money('CZK')->weight('bold'), ])
Users see a chevron icon on the left. Clicking expands the row to show child rows below.
Expansion Baseline
subRowsDefaultExpanded() sets where rows start; the master chevron in the
expander column header moves that baseline at runtime, and the choice outlives
pagination:
$table->subRowsDefaultExpanded()
flattenSubRows() and toggleFlattenMode() were the 1.x names for this and were
removed in 2.0 — flatten mode never flattened anything, it only opened every row.
Sub-Row Relation with Eager Loading
->subRows() accepts dot-notation for eager-loaded relations:
$table->subRows('items.product')
Independent Sub-Row Filtering
$table->subRowsFilterable()
When enabled, the table renders separate filter controls for sub-rows alongside the main filters.
Custom Sub-Row View
Instead of sub-row columns, render a completely custom Blade view:
$table->subRowView('components.order-items-detail')
{{-- resources/views/components/order-items-detail.blade.php --}}<div class="p-4 bg-gray-50"> <table class="w-full text-sm"> @foreach($record->items as $item) <tr> <td>{{ $item->product->name }}</td> <td class="text-right">{{ $item->quantity }}×</td> <td class="text-right font-bold"> {{ number_format($item->subtotal, 2) }} {{ $currency }} </td> </tr> @endforeach @if($showTotals) <tr class="border-t font-bold"> <td colspan="2">Total</td> <td class="text-right">{{ number_format($record->total, 2) }} {{ $currency }}</td> </tr> @endif </table></div>
Sub-Row Livewire State
| Property | Type | Description |
|---|---|---|
$expandedRows |
array |
Keys of expanded parent records |
$flattenMode |
bool|null |
Expansion baseline — the state path is rows.expandAll |
Sub-Rows API
->subRows(string $relation) // Eloquent relation name (dot notation supported)->subRowColumns(array $columns) // Column[] for sub-rows->subRowView(string $view) // custom Blade view (replaces columns)->subRowsFilterable(bool $filterable = true)->subRowsDefaultExpanded(bool $expanded = true)->subRowsExpandable(bool $expandable = true)->subRowsLimit(?int $limit) // max sub-rows before "show more"->subRowsToggleLabel(?string $label)->hasSubRows(): bool->getSubRowColumns(): array
Summary Footer (Aggregates)
The HasSummary trait adds aggregate footer rows — sum, avg, count, min, max, range.
Column-Level Summary
TextColumn::make('amount') ->money('CZK') ->summarize('sum', 'Total') TextColumn::make('price') ->money('CZK') ->summarize('avg', 'Average') TextColumn::make('id') ->summarize('count', 'Records') TextColumn::make('rating') ->numeric(decimalPlaces: 1) ->summarize('min', 'Lowest') TextColumn::make('score') ->numeric() ->summarize('max', 'Highest') TextColumn::make('salary') ->money('CZK') ->summarize('range') // shows "min - max"
Table-Level Summary
$table ->summarizeSum('amount', 'Total Amount') ->summarizeAvg('price', 'Avg Price') ->summarizeCount('id', 'Total Records') ->summarizeMin('rating', 'Min Rating') ->summarizeMax('score', 'Max Score') ->summarizeRange('salary', 'Salary Range')
Summary Scopes
The scope argument (3rd parameter of summarize()) selects which rows are
aggregated. It defaults to 'query' (all filtered rows, via a DB aggregate).
Pass 'page' to aggregate only the current page in memory. A column can carry
more than one summary:
TextColumn::make('amount') ->money('CZK') ->summarize('sum', 'Page Total', scope: 'page') // current page only ->summarize('sum', 'Grand Total', scope: 'query') // all filtered rows (default)
Scopes: 'query' (all filtered), 'page' (current page), 'selection'
(selected rows), 'subRows'.
Custom Summary Formatting
Pass a format closure to summarize(), or use summaryDecimals() for numeric
formatting:
TextColumn::make('revenue') ->summarize('sum', format: fn (float $value) => number_format($value, 0, ',', ' ') . ' CZK') TextColumn::make('total') ->summarize('sum') ->summaryDecimals(2) // → "1 234,50"
How It Works
- Page scope: after results are fetched,
HasSummaryiterates the Collection and computes the aggregate in PHP. - Query scope: a separate
$query->sum('amount')(or avg/count/min/max) is executed against the filtered (but unpaginated) dataset.
Summary API
These methods live on the column (HasSummary):
->summarize( string|Closure $type, // 'sum','avg','count','min','max','range','distinct','median' ?string $label = null, string $scope = 'query', // 'query' | 'page' | 'selection' | 'subRows' ?Closure $format = null, // fn(mixed $value): string ?Closure $when = null, // fn(Builder $query): Builder)->summaryDecimals(int $decimals, string $decimalSeparator = ',', string $thousandsSeparator = ' ') // Shortcuts — each takes (?string $label = null, string $scope = 'query'):->summarizeSum() ->summarizeAvg() ->summarizeCount()->summarizeMin() ->summarizeMax() ->summarizeRange()->summarizeDistinct() ->summarizeMedian()
Polling (Auto-Refresh)
Wire Table supports two polling modes: table-level (refreshes entire table) and row/column-level (refreshes specific cells via PollColumn).
Table-Level Polling
$table->poll('5s') // refresh every 5 seconds
Supported intervals: '1s', '2s', '3s', '5s', '10s', '15s', '30s', '60s'.
Keep Alive (Background Tabs)
$table->poll('5s')->pollKeepAlive()
By default, Livewire stops polling when the browser tab is hidden. pollKeepAlive() overrides this.
Only Visible (Viewport)
$table->poll('5s')->pollOnlyVisible()
Only poll when the table element is in the viewport (uses IntersectionObserver).
Conditional Polling
$table->poll('5s') ->pollWhen(fn () => Job::where('status', 'running')->exists())
Polling starts/stops based on the condition. Checked on each interval.
Custom Poll Method
$table->poll('10s')->pollMethod('refreshData')
Instead of full re-render, calls a specific Livewire method.
Change Detection (Skip Unchanged Renders)
$table->poll('5s')->pollChangeDetection()
Each poll normally re-runs the full query, summaries, and DOM morph even when
nothing changed. With change detection enabled, a cheap checksum
(COUNT(*) + MAX(updated_at) of the filtered query, one SQL query) is
compared between polls — an unchanged checksum skips the render entirely.
Models without timestamps fall back to always rendering. When parent timestamps don't capture relevant changes (e.g. rollup sums over child rows), provide a custom checksum:
$table->poll('5s') ->pollChangeDetection(fn ($query) => (string) $query->max('synced_at'))
The closure receives the filtered query (without ordering) and must return a string that changes whenever a re-render is needed.
A skipped render is always conditional on the request having changed nothing the table displays. Livewire merges everything queued for one component into a single request, so a poll tick — or an inline-cell save, which skips the render for its own reason — can travel together with the user changing the page size, the search, a filter or the sort. In that request the change wins and the table renders; a skip there would leave the browser showing the previous view until the user did something else.
Live Tables (Multi-User)
live() is polling and change detection turned on together, for the case they
exist to serve: several people looking at the same records, each expecting to see
what the others do.
$table->live() // every 5s, only rendering when something moved$table->live('2s')$table->live(broadcast: true) // …and immediately, where Echo is set up
live() is exactly ->poll($interval)->pollChangeDetection(), so everything in
the section above applies. What it adds is a write generation: a counter,
shared across processes and scoped by model, that every write through a table
moves on. Without it, change detection is blind to a write that lands in the same
second as the previous checksum — updated_at is stored to the second, so that
edit is indistinguishable from nothing at all, and the next tick compares against
the same second again. It would not be shown late; it would not be shown. The
counter also retires every cached slice of the table at once, which is how
query caching and a live table can be used together.
Pushing instead of waiting — broadcast: true
live(broadcast: true) also fires TableRecordsChanged whenever a write happens
through the table, and the page subscribes to it. A write then reaches the other
sessions as soon as it commits rather than on their next tick.
The event carries no data — it is a nudge to re-read, not a payload to apply. Each client refreshes through its own component, so its own authorization, filters, sort and page are re-evaluated server-side, exactly as for a poll. That also means the channel has nothing on it worth intercepting: the scope name and nothing else.
No broadcaster is a dependency of this package, and none is privileged.
TableRecordsChanged is a plain Laravel broadcast event with string channel
names, and the client half calls nothing but window.Echo.private() and
window.Echo.leave(). So whichever broadcaster Echo drives in your app — Pusher,
Ably, Reverb — should carry it with no change here, configured exactly as your
app already configures broadcasting.
Worth separating what is verified from what follows from that: the only
broadcaster this path has actually been run against is Reverb, by
workbench/scripts/verify-live-broadcast-real.mjs, which installs what it needs
on demand and is not part of CI or of the driver sweep — no broadcaster is a
dependency of this repository, in any section of any manifest, so the driver
skips unless somebody deliberately sets it up. Pusher and Ably are expected to
work because the package touches only the two Echo methods above — a surface
pinned by BroadcasterAgnosticTest — not because anyone has watched them do it.
The event is ShouldBroadcastNow, so it does not go through your queue. A
queued broadcast would be swallowed entirely by the common setup of a configured
queue with no worker running for it — and swallowed silently, because polling
covers for it and the table still refreshes a moment later. The cost of sending
it inline is stated plainly: the write waits on the broadcaster's HTTP call
before it answers. Against a local Reverb that is sub-millisecond; against a
distant broadcaster having a bad day it is added to every write, and a table that
cannot afford that should leave broadcast off and keep the interval.
It needs an Echo-compatible client and a broadcast connection in your app. Both are the app's, not this package's, and every way this can fail is harmless: no Echo on the page, no connection configured, channel authorization refused, a socket that drops in the afternoon — the table falls back to its interval. The user gets a slower table, never a stale one.
Authorize every live table with one callback, not a line per model:
// routes/channels.phpuse NyonCode\WireTable\Support\LiveChannel; LiveChannel::authorize(fn ($user, string $model) => $user->can('viewAny', $model));
The callback is handed the class name the channel belongs to, already decoded,
so the wire format never leaves the package. Branch on $model when different
tables need different rules; return false to refuse, as in any channel callback.
That is why the channel keeps the class to a single segment
(wire-table.App-Models-Invoice, - for \): Laravel compiles a {placeholder}
to ([^.]+), so a dotted class name could not be matched by a wildcard at all and
every model would have needed its own hand-written Broadcast::channel() line.
Worth insisting on, because a mistyped one raises nothing — the subscription is
refused, the push stops arriving, and polling covers for it, so the broadcast half
is dead and the table looks fine.
LiveChannel::for(Invoice::class) gives the name if you need it directly.
Pausing the poll pauses the push. The listener rides the polling wrapper, so
the Stop control — and a pollWhen() condition turning false — take the
broadcast with them. For Stop that is the point: "stop the table changing under
me" should mean all of it. For pollWhen() it is worth knowing, because that
condition is about the cost of polling rather than about wanting updates: a table
combining it with broadcast: true is not pushed to while the condition is
false. Leave pollWhen() off if you want the push to survive it.
The package never authorizes for you. It registers no channel and calls no policy of its own — who may listen is the application's decision, stated where Laravel expects it. What it does instead is refuse to be quiet about the omission: a subscription the server turns down is reported in the console, naming the call that fixes it. That is the one failure worth being loud about, because it looks exactly like success — the table keeps refreshing on its interval, so nothing appears broken while the push half is dead.
A burst of writes — a fill over fifty rows, a bulk action — is one broadcast per record; the client coalesces them into a single re-read. A re-read is also held off while one of your own cells has a save in flight, since the answer would arrive as of before that write and the cell would rightly ignore it.
->live(string $interval = '5s', bool $broadcast = false)
Row/Column Polling
Use PollColumn for per-cell live updates without refreshing the entire table:
PollColumn::make('job_status') ->interval('3s') ->stateDisplays([...]) ->stopWhen(fn ($state) => $state === 'completed') ->rowLevelPolling()
See Columns — PollColumn for the complete PollColumn API.
Polling API
->poll(string|Closure $interval) // interval string or Closure returning ?string->pollKeepAlive(bool $keepAlive = true)->pollOnlyVisible(bool $onlyVisible = true)->pollWhen(Closure $condition) // fn() => bool->pollMethod(string $method) // Livewire method name->pollChangeDetection(bool|Closure $detector = true) // skip render when data unchanged
Lazy Loading
Defers the initial table render for faster page load. The table loads asynchronously after the page is visible.
$table->lazy()
Custom Placeholder
$table->lazy() ->lazyPlaceholder( '<div class="flex items-center justify-center p-16 text-gray-400"> <svg class="w-8 h-8 animate-spin" ...>...</svg> <span class="ml-3">Loading table...</span> </div>' )
How It Works
- Page renders immediately with the placeholder HTML
- Livewire dispatches an async call to load table content
- Placeholder is replaced with the fully rendered table
- Subsequent interactions (sort, filter, paginate) are normal Livewire calls
lazy() defers the JavaScript too, not just the query and the markup. The
table's Alpine bundles ship with the deferred render, and that is safe for two
reasons: Livewire loads and runs a response's new @assets to completion
before it morphs the markup in, and every wireStack bundle registers its Alpine
components unconditionally rather than only from an alpine:init listener —
that event fires exactly once, when Alpine boots, so a bundle arriving later
would otherwise subscribe to an event that never fires again and register
nothing. The factory therefore exists before the deferred table is initialised.
A custom lazyPlaceholder() replaces the visible skeleton only — it never
changes what loads. And if your layout carries
@wireStackScripts, the shared
controllers are in the document from the first paint anyway, which is what you
want in an app that navigates with wire:navigate.
When to Use
- Dashboard pages with multiple tables — load each lazily
- Tables with complex queries — don't block initial paint
- Below-the-fold tables — load only when scrolled to (combine with
pollOnlyVisible)
Performance Optimization
Simple Pagination
Eliminates the COUNT(*) query:
$table->simplePagination()
Trade-offs:
- No "Showing X of Y" text
- No page number links (only Previous / Next)
- Saves one query per page load on large tables
Cursor Pagination
Offset-free, constant-time pagination:
$table->cursorPagination()
Requirements:
- Table must have a unique, orderable column (usually
idorcreated_at) - Default sort must be set
Trade-offs:
- No random page access (Previous / Next only)
- URL cursors are opaque strings
- Cannot combine with
count()operations
Best for: real-time data feeds, infinite scroll UIs, tables > 1M rows.
Row Partials
A write normally re-renders the table. rowPartials() makes it answer with the
regions it moved instead — the row, and whatever else that row's change
touched.
$table->rowPartials()
On a 25-column, 20-row page with ten editable columns, an inline cell save costs 49.3 ms and 556 kB as an ordinary render, and 3.2 ms and 26 kB as one row.
How it works
Every row is anchored with a plain HTML attribute — wire:partial="row-42" — so
a table of 200 rows pays 200 attributes and nothing else: no registration, no
snapshot growth. On a successful write the server renders that row on its own,
ships it as an effect, and the browser morphs it into its anchor. Nothing else on
the page is rendered, sent, or morphed.
A row is never the whole answer, so the write queues everything its change moved:
| what moved | when |
|---|---|
| the row | always |
| that record's card | on a stackedOnMobile() table — the same record rendered again for the width that hides the table |
| the totals, both footers | when any column has a summary — a total is computed over the whole filtered set, so any write moves it |
| that group's subtotal rows | on a grouped table with group summaries |
What you trade
A row re-rendered on its own keeps its position. An edit that would move the record under the current sort leaves it where it is until the next full render. That is the whole of the trade, and it is why the flag is opt-in: on a wide editable grid, where the edit is the work, it is the right one.
One write still takes the full render, and it is a property of the write rather than of the table: editing the column the table groups by moves the record into another group. That changes the page's shape rather than a row's contents, and no set of regions can describe it.
With client-side markup
A partial is morphed by Wire's own applier rather than by Livewire's morph, so
anything the browser added to a row — markup the server render knows nothing
about — has to be put back afterwards or it is destroyed. Wire announces
wire:partials-applied on document after each batch, carrying the elements it
replaced, and its own packages listen: wire-sortable re-adds the drag handle
cell it prepends to every row, which otherwise vanished on the first inline save
made in reorder mode.
If you decorate rows from your own JavaScript, listen for the same event:
document.addEventListener('wire:partials-applied', ({ detail }) => { detail.elements.forEach((row) => decorate(row)) })
It is an announcement rather than a hook on purpose: a listener repairs what it
owns and cannot cancel the write. Livewire's morph.updating would let a guard
written for a whole-table render skip() the very cell the partial exists to
update.
With polling
Where poll() or live() is on, the same anchors serve the read side.
refreshTable() compares each row on the page against a hash of the record it
last sent and answers with the rows that moved:
- nothing moved → nothing is sent at all, not even markup;
- a row moved → that row (and its card, and the totals);
- the page's shape moved — a row arrived, left, or moved under the sort → the whole table.
This is the case the feature exists for: several people editing one table, where a colleague's write should repaint their row and leave whatever you have half-typed in a cell of your own alone.
Which rows changed is worked out server-side, from your own page. It is deliberately not carried on the broadcast: the channel is scoped to a model class rather than to a viewer, so record keys on it would tell every listener which records exist and change — including the ones their own query would never return.
It compares a hash of the record's own attributes, so it shares
change detection's blind spot: a
change that never touches the parent row — a child-table rollup, a computed
column — is invisible to it. Say so with a pollChangeDetection() closure.
Example
use NyonCode\WireTable\Columns\TextColumn;use NyonCode\WireTable\Columns\TextInputColumn;use NyonCode\WireTable\Table; class InvoiceLines extends Component{ use WithTable; public function table(Table $table): Table { return $table ->model(InvoiceLine::class) ->live() ->rowPartials() ->columns([ TextColumn::make('sku'), TextInputColumn::make('quantity'), TextInputColumn::make('unit_price'), TextColumn::make('total')->summarizeSum(), ]); }}
Editing a quantity sends back that line and the footer total. A colleague's edit on another line arrives on the next tick as that line alone.
Row Partials API
// Answer a write with the regions it moved, rather than the table->rowPartials(bool $condition = true): static // Whether it is on — the honest answer, and what the views ask->usesRowPartials(): bool
Query Caching
Cache query results for a configured TTL:
$table->cacheQuery(ttl: 60) // 60 seconds, auto-generated key$table->cacheQuery(ttl: 300, key: 'users') // 5 minutes, custom key
A cache key is two parts: a namespace saying which table this is, and a
state fingerprint saying which view of it. The namespace is the query's SQL
and bindings by default, or whatever you pass as key:. The fingerprint covers
search, filters, column filters, sort, per-page and the page number, and is
appended to every namespace — a custom key: scopes entries, it does not
replace their identity.
That matters because a cached table serves a paginated slice, not a query:
perPage and the page are applied inside the cached callback, so they never
reach the SQL, and a custom key knows nothing about the sort or the active
filters. If any of those were missing from the key, the table would freeze for
the whole TTL — changing the page size would keep serving the rows cached under
the same key.
To scope entries by tenant or user, either pass key: or override
generateQueryCacheKey() on the component; the state fingerprint is appended
either way.
Uses Cache::remember() — works with any Laravel cache driver.
Chunked Bulk Processing
Process records in batches for memory-efficient bulk operations:
$table->chunk(500, function (Collection $records) { foreach ($records as $record) { $record->process(); }})
Uses chunkById() internally for consistent ordering.
Performance Comparison
| Feature | Queries | Best For |
|---|---|---|
| Standard pagination | 2 (count + select) | < 100k rows |
| Simple pagination | 1 (select) | 100k – 1M rows |
| Cursor pagination | 1 (select) | > 1M rows |
| Cached + standard | 0-2 (cache hit/miss) | Frequently viewed, rarely updated |
| Lazy loading | Same as above (deferred) | Faster initial paint |
Query Debugging
$table->dumpColumns() is the quickest of these: it dumps every column's name,
label, type and sortable/searchable flags and returns the table, so it can be
dropped into the middle of a chain without taking the definition apart.
QueryPlan Inspection
Get the immutable QueryPlan to see exactly what the engine will do:
$plan = $table->debugQueryPlan(); // Joinsforeach ($plan->joins as $join) { echo "{$join->type} JOIN {$join->table} ON {$join->first} {$join->operator} {$join->second}\n";} // Eager loadsdump($plan->eagerLoads); // ['author', 'tags', 'category'] // Aggregatesdump($plan->aggregates); // [AggregateClause(relation: 'comments', function: 'count')] // Filtersdump($plan->filters); // [FilterClause(column: 'role', operator: '=', value: 'admin')] // Searchdump($plan->searchClauses); // [SearchClause(columns: ['name','email'], term: 'john')] // Sortsdump($plan->sortClauses); // [SortClause(column: 'name', direction: 'asc')]
Raw SQL
$sql = $table->toSql();// "SELECT users.* FROM users LEFT JOIN departments ON ... WHERE ... ORDER BY ..."
Column Metadata
$info = $table->getColumnsInfo();// Array of column metadata: DB type, nullable, capabilities, relation paths $dbColumns = $table->getDatabaseColumns();// ['id', 'name', 'email', 'role', 'created_at', ...] $dbInfo = $table->getDatabaseColumnsInfo();// ['name' => ['type' => 'varchar', 'nullable' => false, ...], ...]
SQL Debug
The HasSqlDebug trait (included in WithTable) provides SQL interpolation utilities:
// Get raw SQL with bindings interpolated (for debugging only!)$rawSql = $this->builderToSql($query);// "SELECT * FROM users WHERE role = 'admin' AND created_at >= '2024-01-01'" // Interpolate bindings into a prepared statement$interpolated = $this->interpolateSql($sql, $bindings);
Warning: Interpolated SQL is for debugging only. Never execute it directly — use parameterized queries.
Development Usage
class UserTable extends Component{ use WithTable; public function debugQuery(): void { $table = $this->table(Table::make()); $query = $this->buildTableQuery($table); logger()->debug('Table SQL', [ 'sql' => $this->builderToSql($query), 'plan' => $table->debugQueryPlan(), ]); }}
Layout
A List Instead of a Table
Some surfaces are never a table: an inbox, an activity feed, a media library. Built as one they get argued out of it a column at a time — collapse three columns into one, delete the state column, replace the badge with weight, hide the actions — and you are still left with a header row that cannot be turned off.
$table->layout('list') // or TableLayout::List
The cards render at every width and no <table> is emitted at all. That is
the difference from stackedOnMobile() below, and the whole of it: stacking puts
two renderings of every record in the document and lets CSS choose, because a
table that has to survive a phone needs both. A surface that is never a table
needs one.
Everything around the records stays, which is why this is a layout and not a different page — the search, the filters, the pagination, the selection that survives paging, the bulk actions over it, the exports. Marking twelve thousand notifications read is a selection that outlives a page, and nothing hand-written per module would have grown one.
Two things move because a list has no header row: sorting appears as its own control (the same one a stacked table gets on a phone), and the card's shape comes from the slot vocabulary rather than from column order.
$table ->layout('list') ->columns([ TextColumn::make('subject')->mobileTitle(), TextColumn::make('sender')->mobileSubtitle(), TextColumn::make('received_at')->since()->mobileMeta(), ]) ->collapseActionsOnMobile(true, 1); // the row's verbs behind one trigger
Three things are worth turning off with it, because each is a control that only means something over a grid of columns — and together they are what makes a page announce itself as a table however the records are drawn:
$table ->layout('list') ->selectable(false) // no checkbox on every card, no select-all bar ->perPageSelector(false) // paging stays; `Show [10] records` goes ->columns([ TextColumn::make('subject')->toggleable(false)->mobileTitle(), // nothing to hide ]);
A list that wants to be read as a timeline says when with headings, because it has no column to say it in:
->listHeading(fn ($record) => match (true) { $record->created_at->isToday() => __('Today'), $record->created_at->isYesterday() => __('Yesterday'), default => __('Earlier'),})
Consecutive records answering the same heading share one, so this assumes the
list is already ordered the way the headings run. It draws only — groupBy() is
the feature that reorders, and it needs a real column to order by.
Selection is the one worth thinking about rather than copying: what it buys is acting on the rows ticked on this page, and a header action over the whole filtered set is usually both stronger and quieter.
Stacked on Mobile
Below a breakpoint, columns stack vertically as label-value pairs:
$table->stackedOnMobile(true, 'md') // 2nd arg = breakpoint to stack below (default 'md')
In stacked mode:
- Each row becomes a card
- Each column renders as
Label: Value - Column
visibleFrom()/hiddenFrom()still applies
Row actions render inline in each card header. When a row has several actions, collapse them into a single dropdown group so the header stays tidy:
$table ->stackedOnMobile() ->collapseActionsOnMobile() // one "⋮" trigger per card instead of inline buttons
The collapse only kicks in once a row has 3 or more actions; with fewer, the card keeps them inline. Tune the threshold with the second argument:
->collapseActionsOnMobile(threshold: 2) // collapse from 2 actions up->collapseActionsOnMobile(threshold: 1) // always collapse
Only the mobile stacked cards are affected — the desktop table keeps its inline
action buttons. Any existing ActionGroups are flattened into the single mobile
dropdown (dividers are dropped in the merge), and a card with only one visible
action still shows that action inline. The dropdown inherits the table's
sheetOnMobile() / mobileBreakpoint() settings (bottom-sheet on small screens
by default).
Header Actions on a Phone
The toolbar carries the same crowding problem one level up: the search field,
the filter trigger and the view menu already sit there, and two labelled header
buttons ("New invoice", "Import CSV") push the row into a wrap at phone width.
collapseHeaderActionsOnMobile() folds them into one dropdown:
$table->collapseHeaderActionsOnMobile() // one "⋮" trigger instead of the buttons
Unlike collapseActionsOnMobile() this needs no stackedOnMobile() — the
toolbar is the same toolbar at every width, so the collapse is purely a width
switch. The switch is the table's mobileBreakpoint() (sm by default,
i.e. below 640px), not the stacking breakpoint:
$table ->mobileBreakpoint('md') // fold below 768px instead ->collapseHeaderActionsOnMobile()
It folds from 2 executable header actions up — a lone button is not a crowd, and the toolbar folds sooner than a card's row actions because it shares its row with the search field. Tune it the same way:
->collapseHeaderActionsOnMobile(threshold: 3) // keep two buttons inline, fold from three->collapseHeaderActionsOnMobile(threshold: 1) // always fold
Only actions the viewer may run are counted, so a table whose second action is
gated by an authorization guard keeps the first one as a plain button. The
dropdown is the canonical ActionGroup — it inherits sheetOnMobile() /
mobileBreakpoint(), so it opens as a bottom sheet on a phone by default, and
it collapses to a single inline button when only one action survives its guards.
Both halves sit in the document at every width (CSS decides which is shown), so
the folded copy renders without each action's keyboardShortcut(): a
rendered shortcut is a window listener, and a second binding would run the
action twice on one keypress. The visible desktop button keeps it.
class ListInvoices extends Component{ use WithTable; public function table(Table $table): Table { return $table ->model(Invoice::class) ->columns([ TextColumn::make('number'), TextColumn::make('total')->money('CZK'), ]) ->headerActions([ HeaderAction::make('create') ->label('New invoice') ->icon('plus') ->keyboardShortcut('c') // desktop only — see above ->url(route('invoices.create')), HeaderAction::make('import') ->label('Import CSV') ->icon('arrow-up-tray') ->action(fn () => $this->importInvoices()), ]) ->collapseHeaderActionsOnMobile(); }}
The Card's Anatomy
A card is a record, not the column order in disguise. Five named slots carry the hierarchy — what this is, whose it is, how much — and the rest drops into the label/value grid below:
┌──────────────────────────────────────────────┐│ INV-1001 9 350 Kč ⋮ │ title · metric · actions│ Northwind Traders │ subtitle│ [ paid ] │ meta│ ───────────────────────────────────────── ││ NOTE REFERENCE │ everything else│ First order 2026/114 │└──────────────────────────────────────────────┘
Nothing has to be declared for this: the slots are derived from the columns you already have.
| Slot | Derived from |
|---|---|
title |
the first visible column |
metric |
the last right-aligned column — what money() and numeric() produce |
meta |
badge columns |
subtitle |
the first column no other slot claimed |
| detail grid | everything left |
When the derivation guesses wrong, say so — per column:
TextColumn::make('total')->money()->mobileMetric(),BadgeColumn::make('status')->mobileMeta(),TextColumn::make('reference')->mobileDetail(), // keep it out of the header
…or for the whole table, which wins over both derivation and per-column calls:
use NyonCode\WireTable\Support\MobileCardConfig; $table->mobileCard(fn (MobileCardConfig $card) => $card ->title('number') ->subtitle('customer') ->metric('total') ->meta(['status', 'due_at']));
The metric is set right on the title line in tabular figures, so a column of amounts can be compared down the edge instead of being read one card at a time.
Sub-Rows on a Card
Expanded children render as a list rather than the desktop's nested table: name on the left, its figure on the same right edge as the card's own metric, the supporting detail underneath.
│ 3 items ⌄ ││ ──────────────────────────────────────────── ││ 27" monitor 5 600 Kč ⋮ ││ Unit: 5 600 Kč ││ Mechanical keyboard 2 400 Kč ⋮ ││ Unit: 1 200 Kč ││ Subtotal 9 350 Kč │
Per-parent subtotals, the "Show N more" affordance and per-child actions all work here — they used to be desktop-only, while the card flattened every child into one indistinguishable grid.
Child actions always collapse into a single ⋮ trigger, whatever
collapseActionsOnMobile() says: a child line is narrower than the card holding
it, and two labelled buttons there crush the product name to an ellipsis.
The collapsed toggle names the child count (3 items) when the number is already
in memory, and falls back to Details when it is not — a collapsed row has no
eager-loaded children, so counting would cost one query per card. Add
->withCount('items') to the base query and every card names its count for free.
Totals on a Card
The desktop totals live in a <tfoot> of the table the card layout hides, so a
stacked table used to show no totals at all — in an accounting table, the number
the user came for. They now render below the cards as label/value rows, on the
same right edge as each card's metric, with the same All / This page /
Selection scope toggle the desktop footer has:
│ INV-1003 8 450 Kč │├────────────────────────────────────────────┤│ Showing: [ All ][This page]││ Total items · Items 7 ││ Grand total · Total 35 900 Kč ││ Average · Total 11 967 Kč │
Nothing to configure — a column with summarize*() gets its total here as well
as in the table footer, and sub-row grand totals follow the same way.
Column Breakpoints
// Visible from md up (hidden on mobile)TextColumn::make('email')->visibleFrom('md') // Hidden from lg up (visible only on mobile/tablet)TextColumn::make('phone')->hiddenFrom('lg') // ShortcutsTextColumn::make('address')->onlyOnDesktop() // ≥lgTextColumn::make('avatar')->onlyOnMobile() // <mdTextColumn::make('subtitle')->onlyOnTabletAndUp() // ≥mdTextColumn::make('metadata')->onlyOnLargeScreens() // ≥xl
Per-Record Mobile Display
TextColumn::make('user') ->mobileDisplayUsing(fn ($state, $record) => $record->name) ->desktopDisplayUsing(fn ($state, $record) => "{$record->name} ({$record->email})")
A variant closure supplies the cell's content, exactly as displayUsing()
does. Everything else the column declares — the record link, the icon, the size
and weight, the copy button, the description — still wraps it, so a column does
not quietly lose its affordances at one width:
TextColumn::make('user') ->actionUrl(fn ($record) => route('users.show', $record)) ->copyable() ->mobileDisplayUsing(fn ($state, $record) => $record->name) // still a link, still copyable
Declaring only one variant is fine: the other width falls back to
displayUsing() if there is one, and to the formatted state otherwise. When both
widths render identically the cell is emitted once, without the breakpoint
wrappers.
Column Toggling
Users can show/hide toggleable columns via a column picker dropdown:
// Mark specific columns as toggleableTextColumn::make('phone') ->toggleable() // user can hide/show ->hidden() // start hidden (user can enable) TextColumn::make('notes') ->toggleable() ->visibleFrom('lg') // default visible from lg, but user can override
By default the shown/hidden set lives only for the component's lifetime (it resets on a full page reload).
Remember each user's layout
Call rememberColumns() with a stable key and the table loads the current
user's saved layout on mount and persists it whenever a column is toggled — so
every user keeps their own column arrangement across reloads. A "Reset columns"
control appears in the picker to return to the configured defaults.
$table ->columns([ TextColumn::make('name'), TextColumn::make('email')->toggleable(), TextColumn::make('phone')->toggleable()->hidden(), ]) ->rememberColumns('users-index'); // stable, unique per table
Preferences are scoped by the driver to auth()->user(), so one key serves
every user — it works for any number of tables (distinct keys) and users. A
stored column that no longer exists (renamed/removed) is ignored on load.
Where it is stored is a driver, selected in config('wire-table.preferences'):
| Driver | Persistence | Setup |
|---|---|---|
null |
Not persisted (default) | — |
session |
The user's session | none |
database |
A wire_preferences row per (user, surface) |
publish + migrate |
// config/wire-table.php'preferences' => [ 'default' => env('WIRE_TABLE_PREFERENCES_DRIVER', 'null'), // signed-in users 'guest' => env('WIRE_TABLE_PREFERENCES_GUEST_DRIVER', 'session'), // visitors // ...],
For the database driver, publish and run the migration — it ships with wire-core, because the store is shared:
php artisan vendor:publish --tag="wire-core::migrations"php artisan migrate
The store moved down in 2.0. It began here as
TablePreferenceDriverand atable_preferencestable, because a table's hidden columns were the first thing anyone wanted remembered. A dashboard layout is the same shape — a JSON bag keyed by a surface and a user — and widgets live inwire-core, which table depends on, so from a widget this store could not be reached. It isNyonCode\WireCore\Foundation\Preferences\Contracts\PreferenceDrivernow, the table iswire_preferences, and the column issurface_key. Nothing about how a table is configured changed:config('wire-table.preferences')is still the place. The migration renames an existing installation's table rather than leaving it behind.
Override the driver for a single table (e.g. force the database even when the
global default is session), or plug in your own store implementing
PreferenceDriver:
$table ->rememberColumns('reports') ->preferenceDriver(app(DatabasePreferenceDriver::class));
Saved Views
A saved view is this table's preferences under a name — and the layout the
user is looking at right now is the unnamed one. That is why saved views are not
a second store: savedViews() rides the same driver, the same key and the same
per-user scoping as rememberColumns() above, with one dimension added.
$table ->rememberColumns('orders-index') ->savedViews(); // shares the key it was just given
Pass a key only when a table wants saved views without remembering the
current layout: ->savedViews('orders-index'). A table that calls
savedViews() with no argument and never called rememberColumns() gets saved
views off — silently, because the alternative is inventing a key from the
component's class name, which would move the day anyone renamed the class and
take every stored view with it.
What a view carries
A view is a list of state paths, not a snapshot of the component:
| Carried | Left out |
|---|---|
| Sort column and direction | The selection — a selection is about records, and restoring one ticks boxes the user never ticked; a saved all mode would mean "everything the filter matches" against a filter that has since moved on |
| Page size | The open modal — where the user is standing, not a layout |
| The search term | The cursor and per-row expansion — both name records that may not be in the result set any more |
| Filters and per-column filters | The lazy-load latch, which belongs to one request |
| Hidden columns | |
| Expand-all, collapsed groups, sub-row filters and sub-row sort | |
| The summary scope |
Restoring is not a blind write-back. The hidden-column set is intersected with the columns that still exist and are still toggleable, so a view saved a year ago that names a renamed column hides nothing rather than hiding the wrong thing; and a stored path this version of the framework does not know is ignored rather than seeded into state. Applying a view also resets the page — the records on screen have changed, so the page it was saved on is not this view's page.
In the UI
The switcher lives inside the existing view-options menu, next to the column picker, rather than in a dropdown of its own: the control is already called "view options", and a second trigger would be two places to look for one idea. Saving prompts for a name; each stored view has a row that applies it and a control that deletes it. Deleting a view never touches the layout the user is standing in.
use Livewire\Component;use NyonCode\WireTable\Columns\TextColumn;use NyonCode\WireTable\Concerns\WithTable;use NyonCode\WireTable\Filters\SelectFilter;use NyonCode\WireTable\Table; class ListOrders extends Component{ use WithTable; public function table(Table $table): Table { return $table ->model(Order::class) ->searchable() ->columns([ TextColumn::make('number')->sortable(), TextColumn::make('customer')->toggleable(), TextColumn::make('total')->toggleable(), ]) ->filters([ SelectFilter::make('status')->options([ 'draft' => 'Draft', 'paid' => 'Paid', ]), ]) ->rememberColumns('orders-index') ->savedViews(); }}
A user then filters to paid, sorts by total, hides two columns, and saves it as
"Unpaid this month" — and gets all four back next week from the menu.
Storage and sharing
The driver keys a view on the triple (table, user, view name), which makes two things fall out for free. A user's views are their own, like their column layout. And a shared view — one a whole team sees — is a row with no user, so sharing needs no second mechanism: write one through the driver directly.
app(DatabasePreferenceDriver::class)->save( 'orders-index', null, // no user — everyone sees it ['sort.column' => 'total', 'sort.direction' => 'desc'], 'Biggest first',);
Saving a name that already exists replaces it rather than duplicating it, and an
empty name is refused by all three endpoints — the unnamed bag is the live
layout, so accepting '' would let "Save" overwrite the layout with itself, and
"Delete" throw it away.
Saved views API
->savedViews(?string $key = null) // null reuses the rememberColumns() key; off without one->getSavedViewsKey(): ?string // null when saved views are off
The component endpoints, for a custom view or a test:
$this->saveTableView(string $name): void // capture the current view under a name$this->applyTableView(string $name): void // restore it, and reset the page$this->deleteTableView(string $name): void // drop it; the live layout is untouched$this->getTableViews(): array // the names, for a switcher
Row Context Menu
Let power users right-click a row to open a menu of actions at the cursor —
a shortcut alongside the actions column. The menu's actions are declared
separately by binding each one to the right-click trigger (they are not the
->actions() toolbar), so the menu is explicit rather than an implicit mirror of
the row buttons — pass the same action objects if you want them to match. It uses
the same menu-item styling as the action-group dropdown.
$table ->columns([/* ... */]) ->actions([EditAction::make()]) // the row toolbar ->recordActions([ // a separate right-click menu ViewAction::make()->onContextMenu(), EditAction::make()->onContextMenu(), DeleteAction::make()->onContextMenu(), ]);
- The menu lists exactly the visible menu actions (hidden/unauthorized actions are skipped); a row with no visible action shows no menu.
- Only one context menu is open at a time — right-clicking another row closes the previous.
- It is pinned at the pointer and clamped inside the viewport; it closes on
outside click,
Escape, scroll, or after choosing an action (which runs the action normally, e.g. opening its modal). - This is a desktop pointer feature — touch devices have no context menu, so the actions column remains the primary affordance.
Notifications Per-Table
Override the global notification driver for a specific table:
$table->notificationDriver('livewire') // use Livewire events for this table
Useful when different parts of your app use different notification UIs.
URL State Persistence
Persist table state (search, sort, per-page, filters) in the URL for bookmarkable and shareable links:
public function table(Table $table): Table{ return $table ->model(User::class) ->queryString() ->columns([...]) ->filters([...]);}
URLs then look like:
/users?search=john&sort=name&direction=desc&per_page=25&filter_role=admin
Tracked parameters:
| Parameter | State | Notes |
|---|---|---|
search |
global search | only when the table is searchable |
sort, direction |
sort state | only sortable column names are accepted |
per_page |
page size | only values from perPageOptions() are accepted; -1 is the 'all' option, on a table that offers it |
filter_{name} |
filter value | one parameter per filter |
page |
current page | handled by Livewire's WithPagination; a page past the end re-anchors to the last populated one |
Multi-field filters expand into suffixed parameters: NumberRangeFilter
becomes filter_price_min / filter_price_max, a range DateFilter
becomes filter_created_at_from / filter_created_at_to. Filters using
multiple() accept array syntax (filter_status[]=active&filter_status[]=trial).
Incoming URL values are validated against the table configuration —
unknown sort columns, per-page values outside perPageOptions(), and
parameters for unknown or hidden filters are ignored. The same check runs on
the live wire:model path, so a crafted Livewire payload cannot ask for a page
size the table does not offer.
Multiple Tables Per Page
Parameter names are global per URL. When two query-string-persisted tables render on the same page, give each one a prefix:
$table->queryString('orders_'); // ?orders_search=…&orders_filter_status=…
Notes
- URL seeding wins over
defaultSort()/ filterdefault()values. - Filters whose names contain dots (relationship filters such as
author.name) are not URL-tracked. - The URL updates via
history.replaceState, so typing in the search box does not flood the browser history; parameters disappear again when the state returns to its default.
Browser Testing Selectors
Every interactive part of the table carries a stable data-testid (plus an
accessible name/role where the control is icon-only), so Pest v4 Browser
Testing can target it at the user
level without brittle CSS.
| Part | Selector |
|---|---|
| Search box | data-testid="table-search" (also aria-label) |
| Table filters trigger | data-testid="table-filters-trigger" |
| Filter reset | data-testid="table-filter-reset" |
| Active filter chip / remove | data-testid="filter-chip-{name}" / filter-chip-remove-{name} |
| Column picker trigger | data-testid="table-column-toggle" |
| Page-size selector | data-testid="table-per-page" |
| Pagination | data-testid="table-page-prev" / table-page-next / table-page-{n} |
| Sortable header | data-testid="table-sort-{column}" |
| Per-column filter cell | data-testid="table-filter-{column}" |
| Body cell | data-testid="table-cell-{column}" (+ data-column) |
| Inline-edit cell | data-testid="table-editable-{column}" |
| Row | data-testid="table-row" + data-row-key="{key}" (mobile card: table-card) |
| Select-all / row / card | data-testid="table-select-all" / table-row-select / table-card-select (role="checkbox", aria-label) |
| Sub-row expand | data-testid="table-row-expand" (aria-expanded) |
| Row action | data-testid="action-{name}" (+ aria-label) |
| Header / bulk / menu action | data-testid="header-action-{name}" / bulk-action-{name} / menu-action-{name} |
| Empty-state action | the same testid as its kind (action-{name} / header-action-{name}) — under stackedOnMobile() it matches twice, once per layout, so select the visible one |
| Bulk bar / deselect | data-testid="table-bulk-bar" / table-deselect" |
| Panel filter control | data-testid="filter-{name}" (the input inside a Select / Ternary / custom panel filter — distinct from the header table-filter-{column} cell) |
| Action group trigger | data-testid="action-group-trigger" |
| Copyable cell button | data-testid="cell-copy" |
| Button column cell | data-testid="column-button" |
| Polling toggle | data-testid="polling-toggle" |
| Sub-row controls | data-testid="subrows-master-toggle" / subrows-expand-all-rows / subrows-reset-filters / subrows-show-more / subrows-sort-{column} |
| Summary scope toggle | data-testid="summary-scope-{value}" |
Actions are also targetable by their visible label, and filter options by their text — prefer those for the most user-faithful assertions:
it('filters users by role', function () { $page = visit('/users'); $page->assertSee('Ann')->assertSee('Bob'); // Open the searchable Role filter and pick a value (user-level). $page->click('@table-filter-role') // data-testid ->fill('search', 'Man') ->click('Manager'); $page->assertSee('Bob')->assertDontSee('Ann');}); it('edits the first row via its action', function () { visit('/users') ->within('[data-row-key="1"]', fn ($row) => $row->click('@action-edit')) ->assertSee('Edit user');});
The whole active surface — search, sort, per-column filters, row selection, row actions, the right-click context menu and the column picker — is reachable this way.
Beyond the table, the same convention runs through the shared UI so an end-to-end flow (open a modal, fill a form, confirm) is fully mappable:
Naming convention (so you can derive any hook): every form field has a
form-field-{statePath} container; interactive types additionally expose a
form-{type}-{statePath} control, whose sub-controls append -{action|value|index}.
Plain text / number inputs carry only the container (target it, or the <input>
within) — there is no form-text-{path} hook.
| Surface | Selector |
|---|---|
| Every form field (container) | data-testid="form-field-{statePath}" (+ data-field) |
| Toggle / checkbox / slider | form-toggle-{path}, form-checkbox-{path}, form-slider-{path} |
| Radio / checkbox-list options | form-radio-{path}-{value}, form-checklist-{path}-{value} (+ -select-all / -deselect-all / -search) |
| Repeater / key-value | `form-repeater-{path}-add |
| File / tags | `form-file-{path}-dropzone |
| Date-time picker | `form-datetime-{path}-trigger |
| Color / rating / OTP | form-color-{path} (+ -hex / -swatch-{color}), form-rating-{path}-star-{n}, form-otp-{path}-{i} |
| Editors (markdown/rich/tiptap) | form-editor-{path} (body) + `-{command |
| Field / affix / hint actions | field-action-{path}-{name} |
| Searchable select (forms + filters) | select-trigger / select-search / select-option-{value} / select-clear; option-action triggers form-select-{path}-create-option / -edit-option; create/edit-option modals: `select-create-save |
| MorphToSelect | form-select-{path}-type (morph type) / form-select-{path}-record (record select) |
| Modal / slide-over / confirmation | modal-close, slide-over-close, modal-cancel / modal-submit, modal-back / modal-next, confirmation-confirm / confirmation-cancel, modal-footer-action-{name} |
| Wizard / tabs / section / callout | wizard-step-{i} / wizard-back / wizard-next, tab-{i}, section-toggle, callout-dismiss |
| Toasts | toast-dismiss, toast-action-{i}, toast-expand |
| Infolist actions | infolist-action-{name} |
| Sortable drag handle | sortable-handle (role="button", aria-label) |
Custom Views
Custom Table View
$table->view('my-custom-table-view')
Wire Table resolves views with namespace support. You can publish and override the default views:
php artisan vendor:publish --tag=wire-table::views
Published to resources/views/vendor/wire-table/.
HasView Trait
The HasView trait provides view resolution logic:
// Resolves in order:// 1. Explicit view set via ->view()// 2. Package view: wire-table::table$table->getView();
Complete Real-World Example
class OrderTable extends Component{ use WithTable; protected $queryString = [ 'tableSearch' => ['except' => '', 'as' => 'q'], 'tableSortColumn' => ['except' => '', 'as' => 'sort'], 'tableFilters' => ['except' => [], 'as' => 'f'], ]; public function table(Table $table): Table { return $table ->model(Order::class) ->modifyQueryUsing(fn ($q) => $q->where('tenant_id', auth()->user()->tenant_id)) ->columns([ TextColumn::make('number') ->fontFamily('mono') ->searchable() ->sortable() ->copyable(), StackedColumn::make('customer') ->avatar('customer.avatar_url') ->primary('customer.name') ->secondary('customer.email') ->circular() ->searchable() ->searchColumns(['customer.name', 'customer.email']), TextColumn::make('items.count') ->label('Items') ->alignCenter() ->sortable(), TextColumn::make('total') ->money('CZK') ->sortable() ->alignRight() ->weight('bold') ->summarize('sum', 'Page Total', scope: 'page') ->summarize('sum', 'Grand Total', scope: 'query'), BadgeColumn::make('status') ->colors([ 'draft' => 'gray', 'pending' => 'warning', 'processing' => 'info', 'shipped' => 'success', 'delivered' => 'primary', 'cancelled' => 'danger', ]) ->icons([ 'pending' => 'clock', 'processing' => 'refresh', 'shipped' => 'truck', 'delivered' => 'check', 'cancelled' => 'x', ]), TextColumn::make('created_at') ->dateTime('d.m.Y H:i') ->sortable() ->size('sm') ->textColor('gray') ->visibleFrom('lg'), PollColumn::make('shipping_status') ->interval('30s') ->badge() ->colors(['success' => 'delivered', 'info' => 'in_transit', 'gray' => 'waiting']) ->pollWhile(fn ($state) => $state === 'in_transit') ->visibleFrom('md'), ]) ->filters([ SelectFilter::make('status') ->options([ 'pending' => 'Pending', 'processing' => 'Processing', 'shipped' => 'Shipped', 'delivered' => 'Delivered', 'cancelled' => 'Cancelled', ]) ->multiple() ->default(['pending', 'processing']), DateFilter::make('created_at') ->range() ->fromLabel('From') ->toLabel('Until'), NumberRangeFilter::make('total') ->min(0)->max(1000000)->step(100), TernaryFilter::make('has_invoice') ->label('Invoice Generated') ->query(fn (Builder $q, bool $value) => $value ? $q->whereNotNull('invoice_id') : $q->whereNull('invoice_id')), ]) ->actions([ Action::make('view') ->icon('eye') ->url(fn ($r) => route('orders.show', $r)), ActionGroup::make('more', [ Action::make('invoice') ->icon('document') ->visible(fn ($r) => $r->status !== 'draft') ->action(fn ($r) => $r->generateInvoice()), Action::make('duplicate') ->icon('copy') ->action(fn ($r) => $r->replicate()->save()), Action::divider(), Action::make('cancel') ->icon('x') ->color('danger') ->visible(fn ($r) => ! in_array($r->status, ['delivered', 'cancelled'])) ->requiresConfirmation() ->modalHeading('Cancel this order?') ->action(fn ($r) => $r->cancel()), ]), ]) ->bulkActions([ BulkAction::make('export') ->icon('download') ->action(fn ($records) => $this->export($records)), DeleteBulkAction::make(), ]) ->headerActions([ HeaderAction::make('create') ->label('New Order') ->icon('plus') ->url(route('orders.create')), ]) ->subRows(fn ($record) => $record->items) ->subRowColumns([ TextColumn::make('product.name'), TextColumn::make('quantity')->alignCenter(), TextColumn::make('unit_price')->money('CZK'), TextColumn::make('subtotal')->money('CZK')->weight('bold'), ]) ->defaultSort('created_at', 'desc') ->searchable() ->paginated() ->perPage(25) ->perPageOptions([10, 25, 50, 100]) ->selectable() ->striped() ->hoverable() ->stackedOnMobile() ->emptyState( heading: 'No orders found', description: 'Create your first order to get started.', icon: 'shopping-cart', ) ->emptyStateActions([ Action::make('createFirstOrder') ->label('New Order') ->icon('plus') ->url(route('orders.create')), ]); }}