K

Table

Advanced Features

The HasSubRows trait enables expandable child rows for hierarchical data — orders → items, categories → products, departments → employees.

On this page

Table of Contents

  1. Sub-Rows (Expandable Rows)
  2. Summary Footer (Aggregates)
  3. Polling (Auto-Refresh)
  4. Lazy Loading
  5. Performance Optimization
  6. Query Debugging
  7. SQL Debug
  8. Responsive Layout
  9. Column Toggling
  10. Row Context Menu
  11. Notifications Per-Table
  12. URL State Persistence
  13. Browser Testing Selectors
  14. 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() is a deprecated alias for the same thing — it never flattened anything, it only opened every row. toggleFlattenMode() still works and now calls toggleAllRowExpansion().

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 (deprecated alias of 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)
->flattenSubRows(bool $flatten = true) // deprecated: subRowsDefaultExpanded()
->hasSubRows(): bool
->getSubRowColumns(): array

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

  1. Page scope: after results are fetched, HasSummary iterates the Collection and computes the aggregate in PHP.
  2. 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.php
use 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

  1. Page renders immediately with the placeholder HTML
  2. Livewire dispatches an async call to load table content
  3. Placeholder is replaced with the fully rendered table
  4. 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 id or created_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.

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

QueryPlan Inspection

Get the immutable QueryPlan to see exactly what the engine will do:

$plan = $table->debugQueryPlan();
 
// Joins
foreach ($plan->joins as $join) {
echo "{$join->type} JOIN {$join->table} ON {$join->first} {$join->operator} {$join->second}\n";
}
 
// Eager loads
dump($plan->eagerLoads); // ['author', 'tags', 'category']
 
// Aggregates
dump($plan->aggregates); // [AggregateClause(relation: 'comments', function: 'count')]
 
// Filters
dump($plan->filters); // [FilterClause(column: 'role', operator: '=', value: 'admin')]
 
// Search
dump($plan->searchClauses); // [SearchClause(columns: ['name','email'], term: 'john')]
 
// Sorts
dump($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(),
]);
}
}

Responsive Layout

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')
 
// Shortcuts
TextColumn::make('address')->onlyOnDesktop() // ≥lg
TextColumn::make('avatar')->onlyOnMobile() // <md
TextColumn::make('subtitle')->onlyOnTabletAndUp() // ≥md
TextColumn::make('metadata')->onlyOnLargeScreens() // ≥xl

Per-Record Mobile Display

TextColumn::make('user')
->mobileDisplayUsing(fn ($record) => $record->name)
->desktopDisplayUsing(fn ($record) => "{$record->name} ({$record->email})")

Column Toggling

Users can show/hide toggleable columns via a column picker dropdown:

// Mark specific columns as toggleable
TextColumn::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 table_preferences row per (user, table) 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:

php artisan vendor:publish --tag="wire-table::migrations"
php artisan migrate

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 TablePreferenceDriver:

$table
->rememberColumns('reports')
->preferenceDriver(app(DatabasePreferenceDriver::class));

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 with rowContextMenu([...]) (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
->rowContextMenu([ // a separate right-click menu
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
]);
  • 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).
  • Action groups are flattened into the menu.
  • 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() / filter default() 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')),
]);
}
}