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.

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 — and with the Alpine bundles the table will need
  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

Step 1 is not a detail you can skip past. The bundles behind dropdowns, row selection and the record controller register their Alpine components from an alpine:init listener, and that event fires exactly once — when Alpine boots. A bundle arriving with the deferred markup would land after it, subscribe to an event that never fires again, and register nothing; the table would then come up with every dropdown dead and each sheet backdrop stuck over the page. So the placeholder render ships them, and the markup that replaces it initialises normally.

Which bundles load follows the table's own configuration: the dropdown bundle always (the toolbar is built from dropdowns), the selection bundle with selectable(), and the record controller whenever the table mounts it at all — record-action pointer bindings, a row context menu, grid keyboard semantics, drag-select or Shift-range selection. A custom lazyPlaceholder() replaces the visible skeleton only — it never changes what loads.

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).

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
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}
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',
);
}
}