Tables

Tables display data in a tabular format. The package ships two halves that are meant to be used together: a set of Blade components for the markup (<x-table>, <x-table.thead>, <x-table.tr>, <x-table.th>, <x-table.td>, <x-table.header>, <x-table.filter>) and an abstract Livewire base class, TableComponent, that supplies searching, sorting, filtering and pagination.

You are not forced to use both. A plain <x-table> with hard-coded rows is perfectly valid markup. But as soon as you want a search box, a filter dropdown or clickable column headers, extend TableComponent — everything else is already wired up for you.

Basic Markup

The markup mirrors a plain HTML table. <x-table> wraps the <table> in a scroll container, <x-table.thead> and <x-table.tbody> render <thead> / <tbody>, and <x-table.tr> renders a row.

Every <x-table.th> needs a name — it is written to the data-column-name attribute and decides whether the column is sortable. Columns that are not listed in the component's $sortable array simply render as a plain header cell.

Invoice
Customer
Total
INV-0001
Alfonzo Pollich
129.00 €
INV-0002
Maryse Bergnaum
48.50 €
INV-0003
Kaley Schowalter
1,240.00 €
1<x-table>
2 <x-table.thead>
3 <x-table.tr>
4 <x-table.th name="invoice">Invoice</x-table.th>
5 <x-table.th name="customer">Customer</x-table.th>
6 <x-table.th name="total">Total</x-table.th>
7 </x-table.tr>
8 </x-table.thead>
9 
10 <x-table.tbody>
11 <x-table.tr>
12 <x-table.td>INV-0001</x-table.td>
13 <x-table.td>Alfonzo Pollich</x-table.td>
14 <x-table.td>129.00 &euro;</x-table.td>
15 </x-table.tr>
16 <x-table.tr>
17 <x-table.td>INV-0002</x-table.td>
18 <x-table.td>Maryse Bergnaum</x-table.td>
19 <x-table.td>48.50 &euro;</x-table.td>
20 </x-table.tr>
21 <x-table.tr>
22 <x-table.td>INV-0003</x-table.td>
23 <x-table.td>Kaley Schowalter</x-table.td>
24 <x-table.td>1,240.00 &euro;</x-table.td>
25 </x-table.tr>
26 </x-table.tbody>
27</x-table>

Inner Borders

Add the inner-border attribute to draw vertical separators between the columns. It adds the table--inner-border class to the <table> element.

Invoice
Customer
Total
INV-0001
Alfonzo Pollich
129.00 €
INV-0002
Maryse Bergnaum
48.50 €
1<x-table inner-border>
2 <x-table.thead>
3 <x-table.tr>
4 <x-table.th name="invoice">Invoice</x-table.th>
5 <x-table.th name="customer">Customer</x-table.th>
6 <x-table.th name="total">Total</x-table.th>
7 </x-table.tr>
8 </x-table.thead>
9 
10 <x-table.tbody>
11 <x-table.tr>
12 <x-table.td>INV-0001</x-table.td>
13 <x-table.td>Alfonzo Pollich</x-table.td>
14 <x-table.td>129.00 &euro;</x-table.td>
15 </x-table.tr>
16 <x-table.tr>
17 <x-table.td>INV-0002</x-table.td>
18 <x-table.td>Maryse Bergnaum</x-table.td>
19 <x-table.td>48.50 &euro;</x-table.td>
20 </x-table.tr>
21 </x-table.tbody>
22</x-table>

Icons, Width and Alignment

Header cells accept an icon that is rendered in front of the label, and both header and body cells accept centered. Any other attribute — width, colspan, title, wire:key — is forwarded to the <th> / <td> element.

Customer
Total
Actions
1
Alfonzo Pollich
129.00 €
2
Maryse Bergnaum
48.50 €
3
Kaley Schowalter
1<x-table>
2 <x-table.thead>
3 <x-table.tr>
4 {{-- No slot content: the header cell only shows the icon --}}
5 <x-table.th name="reference" width="60px" icon="hashtag--mini" centered />
6 
7 <x-table.th name="customer" icon="user--outline">Customer</x-table.th>
8 
9 {{-- The class ends up on the inner .cell element, every other attribute on the <th> --}}
10 <x-table.th name="total" class="text-primary-500" centered>Total</x-table.th>
11 
12 <x-table.th name="actions" width="80px" centered>Actions</x-table.th>
13 </x-table.tr>
14 </x-table.thead>
15 
16 <x-table.tbody>
17 <x-table.tr>
18 <x-table.td centered>1</x-table.td>
19 <x-table.td>Alfonzo Pollich</x-table.td>
20 <x-table.td centered>129.00 &euro;</x-table.td>
21 <x-table.td centered>
22 <x-button type="white" size="xs" icon="pencil--mini" />
23 </x-table.td>
24 </x-table.tr>
25 <x-table.tr>
26 <x-table.td centered>2</x-table.td>
27 <x-table.td>Maryse Bergnaum</x-table.td>
28 <x-table.td centered>48.50 &euro;</x-table.td>
29 <x-table.td centered>
30 <x-button type="white" size="xs" icon="pencil--mini" />
31 </x-table.td>
32 </x-table.tr>
33 <x-table.tr>
34 <x-table.td centered>3</x-table.td>
35 <x-table.td>Kaley Schowalter</x-table.td>
36 {{-- An empty cell receives the "cell--empty" class --}}
37 <x-table.td centered></x-table.td>
38 <x-table.td centered>
39 <x-button type="white" size="xs" icon="pencil--mini" />
40 </x-table.td>
41 </x-table.tr>
42 </x-table.tbody>
43</x-table>

Markup Component Reference

All table markup components and their attributes:

Component Attribute Type Description
<x-table> inner-border Boolean Adds the table--inner-border class. The table also receives wire:loading.class="table--loading" automatically.
<x-table.thead> — — Renders <thead class="table__head">.
<x-table.tbody> — — Renders <tbody class="table__body">.
<x-table.tr> — — Renders <tr class="table__row">. Give it a wire:key when you render it in a loop.
<x-table.th> name String Required. The column key. If it appears in $sortable, the cell becomes clickable and calls sortBy('<name>').
icon String Icon name rendered before the label, e.g. user--outline.
centered Boolean Adds cell--centered to the inner cell.
<x-table.td> centered Boolean Adds cell--centered to the inner cell.
<x-table.header> prefix String Required. Prefixes the generated element IDs so several tables can live on one page.
total String / Slot Rendered on the right of the header. Printed unescaped, so you may pass markup.
actions String / Slot Buttons on the far right. Printed unescaped.
search-placeholder String Placeholder of the search input. Defaults to the translation of Search.
prepend-search
append-search
String / Slot Replaces / adds the content in front of and behind the search input. Printed unescaped.
<x-table.filter> prefix String Required. Prefixes the generated filter element IDs. Rendered automatically by <x-table.header> whenever the component defines filters.

The TableComponent Base Class

MaskowLabs\BasicComponents\Components\Livewire\TableComponent is an abstract Livewire component. The absolute minimum is a $model and a render() that passes the result of getEntries() into the view.

getEntries() builds the query in a fixed order — search, filters, queryModifier(), sorting — and returns a LengthAwarePaginator. The WithPagination trait is already used by the base class.

1// app/Livewire/UserTable.php
2 
3namespace App\Livewire;
4 
5use App\Models\User;
6use MaskowLabs\BasicComponents\Components\Livewire\TableComponent;
7 
8class UserTable extends TableComponent
9{
10 protected $model = User::class;
11 
12 public function render()
13 {
14 return view('livewire.user-table', [
15 'entries' => $this->getEntries(),
16 ]);
17 }
18}

Properties and Methods

Everything TableComponent gives you:

Member Visibility Description
$model protected The Eloquent model class the table queries, e.g. User::class. There is no default — set it.
$perPage protected int Rows per page. Default 10. Also decides how many skeleton rows placeholder() renders.
$sortable protected array Column names that may be sorted. Only these render a clickable <th>. Default [].
$sortColumn public string The column currently sorted by. Empty string means no orderBy at all. Bound to the sort query parameter.
$sortDirection public string asc or desc. Bound to the dir query parameter.
$searchable protected array Columns the search box looks in. Default [] — while empty, the search is never applied.
$minSearchLength public int The search only runs once the term is at least this long. Default 3.
$search public string The current search term. Bound to the q query parameter.
$activeFilters public array The current filter values, keyed by the filter's column. This is what the filter inputs are bound to.
$filterOptions public array The result of filters(), converted to plain arrays in mount(). Read by <x-table.filter>.
filters() public Returns an array of TableFilter instances. Anything else throws an InvalidArgumentException. Default [].
getEntries() protected Builds and paginates the query. Call it in render().
queryModifier($query) protected Hook applied to every query — eager loading, tenant scoping, permanent conditions.
searchModifier($query) protected Builds the search clause from $searchable. Override to replace the whole search.
searchColumnModifier($query, $column, $search) protected Builds the condition for a single column. Default: LIKE %search%.
filterModifier($query) protected Loops over $activeFilters and dispatches to filterTypeModifier().
filterTypeModifier($query, $type, $column, $value) protected Applies the built-in condition for select / checkbox, then calls filterItemModifier().
filterItemModifier($query, $type, $column, $value) protected Your hook — the place to implement filters marked with handleManually().
sortBy($column) public Toggles the direction, sets the column and jumps back to page 1. Wired to every sortable <th>.
hasActiveFilters() public bool true when any filter differs from its default. Unchecked checkboxes and empty values do not count.
resetTable() public Resets every filter to its default() (or null) and clears the search.
rememberFilters() public Stores $activeFilters in the session under filters.<md5 of the class name>.
hasSessionFilters() public bool true when filters are stored in the session for this table class.
resetSessionFilters() public Forgets the stored filters and calls resetTable().
placeholder() public Table-shaped skeleton used by Livewire's lazy loading. Renders $perPage skeleton rows.

A Complete Example

A full table with search, a filter dropdown, sortable columns and pagination. The preview is live — search, sort, filter and page through it. It queries a Sample model with the columns name, email, phone, age and status.

This documentation page itself extends TableComponent, which is why every table preview on this page is live. All of them share the same component instance — searching in one header therefore also changes what the other previews show.

Status
Only 21 and older
0 samples
Name
E-Mail
Age
Status
Actions
No samples found.
1// app/Livewire/SampleTable.php
2 
3namespace App\Livewire;
4 
5use App\Models\Sample;
6use Illuminate\Database\Eloquent\Builder;
7use MaskowLabs\BasicComponents\Components\Livewire\TableComponent;
8use MaskowLabs\BasicComponents\Helpers\TableFilter;
9 
10class SampleTable extends TableComponent
11{
12 /** The Eloquent model the table queries. */
13 protected $model = Sample::class;
14 
15 /** Rows per page. */
16 protected int $perPage = 5;
17 
18 /** Column the table is sorted by on first render. */
19 public string $sortColumn = 'name';
20 
21 /** Only these columns render a clickable sort handle in the <th>. */
22 protected array $sortable = ['id', 'name', 'email', 'age', 'status'];
23 
24 /** Columns the search box looks in. */
25 protected array $searchable = ['name', 'email', 'phone'];
26 
27 /** Filters rendered inside the filter dropdown of <x-table.header>. */
28 public function filters(): array
29 {
30 return [
31 (new TableFilter())
32 ->type('select')
33 ->column('status')
34 ->label('Status')
35 ->placeholder('Any status')
36 ->help('Only show samples with the selected status.')
37 ->allowMultiple()
38 ->searchable()
39 ->as('status')
40 ->options([
41 ['value' => 'active', 'label' => 'Active'],
42 ['value' => 'pending', 'label' => 'Pending'],
43 ['value' => 'inactive', 'label' => 'Inactive'],
44 ]),
45 
46 (new TableFilter())
47 ->type('checkbox')
48 ->column('is_of_age')
49 ->label('Only 21 and older')
50 ->handleManually(),
51 ];
52 }
53 
54 /**
55 * "is_of_age" is not a database column, so the filter was flagged with
56 * handleManually() and is translated into a real condition here.
57 */
58 protected function filterItemModifier(Builder $query, string $type, string $column, mixed $value): Builder
59 {
60 if ($column === 'is_of_age' && $value) {
61 $query->where('age', '>=', 21);
62 }
63 
64 return $query;
65 }
66 
67 public function render()
68 {
69 return view('livewire.sample-table', [
70 'entries' => $this->getEntries(),
71 ]);
72 }
73}

The Matching View

And the view that belongs to it. $entries is the paginator that render() passed in.

1{{-- resources/views/livewire/sample-table.blade.php --}}
2 
3<div>
4 <x-table.header
5 prefix="samples"
6 :search-placeholder="__('Search name, e-mail or phone')"
7 :total="__(':count samples', ['count' => $entries->total()])"
8 >
9 <x-slot:actions>
10 <x-button type="primary" size="sm" icon="plus--mini">@lang('New')</x-button>
11 </x-slot:actions>
12 </x-table.header>
13 
14 <x-table>
15 <x-table.thead>
16 <x-table.tr>
17 <x-table.th name="id" width="60px" icon="hashtag--mini" centered />
18 <x-table.th name="name" icon="user--outline">Name</x-table.th>
19 <x-table.th name="email" icon="envelope--outline">E-Mail</x-table.th>
20 <x-table.th name="age" centered>Age</x-table.th>
21 <x-table.th name="status">Status</x-table.th>
22 <x-table.th name="actions" width="80px" centered>Actions</x-table.th>
23 </x-table.tr>
24 </x-table.thead>
25 
26 <x-table.tbody>
27 @forelse($entries as $entry)
28 <x-table.tr wire:key="sample-{{ $entry->id }}">
29 <x-table.td centered>{{ $entry->id }}</x-table.td>
30 <x-table.td>{{ $entry->name }}</x-table.td>
31 <x-table.td>{{ $entry->email }}</x-table.td>
32 <x-table.td centered>{{ $entry->age }}</x-table.td>
33 <x-table.td>
34 <x-badge :type="match($entry->status) {
35 'active' => 'success',
36 'pending' => 'warning',
37 default => 'gray',
38 }">{{ ucfirst($entry->status) }}</x-badge>
39 </x-table.td>
40 <x-table.td centered>
41 <x-button type="white" size="xs" icon="pencil--mini" />
42 </x-table.td>
43 </x-table.tr>
44 @empty
45 <x-table.tr>
46 <x-table.td colspan="6" centered>
47 <span class="text-gray-500">@lang('No samples found.')</span>
48 </x-table.td>
49 </x-table.tr>
50 @endforelse
51 </x-table.tbody>
52 </x-table>
53 
54 {{ $entries->links(view: 'basic-components::components.blade.pagination') }}
55</div>

Sorting

List every column the user may sort by in $sortable. A <x-table.th> whose name is in that list gets a wire:click="sortBy('…')", the cell--sortable class and a pair of sort chevrons. The active column additionally gets cell--sorted plus cell--sorting-asc or cell--sorting-desc.

1// Only the listed columns become clickable in the <thead>.
2protected array $sortable = ['id', 'name', 'email', 'age'];
3 
4// Optional: the column the table starts with. Leave empty for no sorting at all.
5public string $sortColumn = 'name';
6 
7// Optional: 'asc' (default) or 'desc'.
8public string $sortDirection = 'asc';

Sorting — Markup

Nothing to wire up in the view: the name decides everything.

1<x-table.thead>
2 <x-table.tr>
3 {{-- "name" is in $sortable -> clickable, renders the sort chevrons --}}
4 <x-table.th name="name">Name</x-table.th>
5 
6 {{-- "age" is in $sortable -> clickable --}}
7 <x-table.th name="age" centered>Age</x-table.th>
8 
9 {{-- "actions" is NOT in $sortable -> plain header cell, no wire:click --}}
10 <x-table.th name="actions" centered>Actions</x-table.th>
11 </x-table.tr>
12</x-table.thead>

Searching

The search box lives in <x-table.header> and is bound with wire:model.live.debounce.250ms. It only produces a query once the term reaches $minSearchLength characters and $searchable is not empty.

The columns are combined with OR: a row matches when at least one of the listed columns matches.

Note that 'strict' is not an exact match. The mode only controls whether the search term is split on spaces before it is handed to searchColumnModifier(); the generated condition is a LIKE %…% in both cases. Override searchColumnModifier() if you need a real equality check.

1/**
2 * Columns the search box looks in.
3 *
4 * A numeric key means "fuzzy": the search term is split on spaces and every
5 * part must match the column with a LIKE %...% condition.
6 *
7 * The value 'strict' means the whole, unsplit search term is matched against
8 * the column instead - still with LIKE %...%, not with an equality check.
9 */
10protected array $searchable = [
11 'name', // fuzzy, every word must match
12 'email', // fuzzy
13 'customer_number' => 'strict', // the whole term is matched at once
14];
15 
16/**
17 * The search is only applied once the term reaches this length.
18 * Set it to 1 to search on every keystroke.
19 */
20public int $minSearchLength = 3;

Customizing the Search

Override searchColumnModifier() to change how a single column is matched — for relations, prefix matches or full-text conditions.

1use Illuminate\Database\Eloquent\Builder;
2 
3/**
4 * Called once per searchable column and search part.
5 * The default implementation is: $query->where($column, 'LIKE', '%'.$search.'%');
6 */
7protected function searchColumnModifier(Builder $query, string $column, string $search): Builder
8{
9 // Search a relation instead of a plain column.
10 if ($column === 'company.name') {
11 return $query->orWhereHas('company', fn ($q) => $q->where('name', 'LIKE', '%'.$search.'%'));
12 }
13 
14 // Match the start of the string only - lets the database use the index.
15 if ($column === 'customer_number') {
16 return $query->where($column, 'LIKE', $search.'%');
17 }
18 
19 return $query->where($column, 'LIKE', '%'.$search.'%');
20}

The Table Header

<x-table.header> renders the search box, the filter dropdown (only when the component defines filters), an optional total and an optional action area. The prefix attribute is required — it namespaces the generated element IDs so that two tables on the same page do not collide.

total, actions, prepend-search and append-search are printed unescaped, so you can pass them either as a string attribute or, more comfortably, as a named slot.

Status
Only 21 and older
0 entries
1<x-table.header
2 prefix="header-example"
3 :search-placeholder="__('Search name, e-mail or phone')"
4 :total="__(':count entries', ['count' => $this->getEntries()->total()])"
5>
6 <x-slot:prependSearch>
7 <x-icon name="magnifying-glass--mini" />
8 </x-slot:prependSearch>
9 
10 <x-slot:actions>
11 <x-button type="white" size="sm" icon="arrow-down-tray--mini">@lang('Export')</x-button>
12 <x-button type="primary" size="sm" icon="plus--mini">@lang('New')</x-button>
13 </x-slot:actions>
14</x-table.header>

Filters

Return an array of MaskowLabs\BasicComponents\Helpers\TableFilter objects from filters(). Each one becomes a row in the filter dropdown and a key in $activeFilters.

Two types exist: select renders an <x-select> and produces where($column, $value) (or whereIn() for multi-selects), and checkbox renders an <x-checkbox> and produces where($column, 1) / where($column, 0).

1use MaskowLabs\BasicComponents\Helpers\TableFilter;
2 
3public function filters(): array
4{
5 return [
6 // Select filter - the value is matched with where('status', $value)
7 (new TableFilter())
8 ->type('select')
9 ->column('status')
10 ->label('Status')
11 ->placeholder('Any status')
12 ->options([
13 ['value' => 'active', 'label' => 'Active'],
14 ['value' => 'pending', 'label' => 'Pending'],
15 ['value' => 'inactive', 'label' => 'Inactive'],
16 ]),
17 
18 // Checkbox filter - when checked, the value is matched with where('archived', 1)
19 (new TableFilter())
20 ->type('checkbox')
21 ->column('archived')
22 ->label('Show archived')
23 ->help('Archived entries are hidden by default.'),
24 ];
25}

The Filter Dropdown

<x-table.header> renders <x-table.filter> for you as soon as filters() returns anything. You can also place it on its own — it only needs a prefix.

The dropdown always ends with three buttons: Reset (resetTable(), disabled while no filter is active), Forget (resetSessionFilters(), disabled while nothing is stored) and Keep filters (rememberFilters()).

Status
Only 21 and older
No filters active.
1<div class="d-flex align-items-center gap-2">
2 {{-- The filter dropdown on its own, outside of <x-table.header> --}}
3 <x-table.filter prefix="standalone-example" />
4 
5 <span class="text-gray-500">
6 @if($this->hasActiveFilters())
7 @lang('Filters are active.')
8 @else
9 @lang('No filters active.')
10 @endif
11 </span>
12</div>

TableFilter Reference

Every public method of TableFilter. All of them are fluent and return the filter, so they chain. The object is built with a plain new TableFilter().

Method Required Description
type(string $type) yes 'select' or 'checkbox'. Anything else throws immediately.
column(string $column) yes The database column. Doubles as the key in $activeFilters and as the query parameter name. With handleManually() it may be any identifier.
label(string $label) yes The label shown in the filter dropdown.
options(array $options) for select An array of arrays, each with a value and a label key. A malformed entry throws an InvalidArgumentException.
placeholder(string $placeholder) no Placeholder of the select. Ignored by the checkbox type.
help(string $help) no Renders a small help icon next to the label with the text as a tooltip.
default(string $default) no Pre-selected value, applied in mount() and used as the except value of the query string. A value equal to the default does not count as an active filter.
allowMultiple(bool $multiple = true) no Turns the select into a multi-select with checkboxes. The value becomes an array and the condition becomes whereIn(). Select only.
searchable(bool $searchable = true) no Adds a search field inside the dropdown. Select only.
as(string $queryString) no Alias for the query parameter. Without it, the column name is used.
handleManually() no Skips the built-in where() / whereIn(). You implement the condition in filterItemModifier().
get() — Validates and converts the filter into the plain array stored in $filterOptions. Called by TableComponent::mount() — you never call it yourself.

Multiple and Searchable Filters

allowMultiple() makes the select accept several values at once — the condition switches from where() to whereIn() automatically. Combine it with searchable() as soon as the option list gets long.

1use App\Models\Category;
2use MaskowLabs\BasicComponents\Helpers\TableFilter;
3 
4public function filters(): array
5{
6 return [
7 /**
8 * allowMultiple() turns the dropdown into a checkbox list and makes the
9 * value an array. The generated condition becomes whereIn('status', [...]).
10 *
11 * searchable() adds a search field inside the dropdown - useful as soon as
12 * the option list gets longer than a handful of entries.
13 */
14 (new TableFilter())
15 ->type('select')
16 ->column('category_id')
17 ->label('Category')
18 ->placeholder('All categories')
19 ->allowMultiple()
20 ->searchable()
21 ->options(
22 Category::orderBy('name')
23 ->get()
24 ->map(fn ($category) => [
25 'value' => $category->id,
26 'label' => $category->name,
27 ])
28 ->all()
29 ),
30 ];
31}

Default Values

A filter with a default() starts out pre-selected. As long as the value equals the default, hasActiveFilters() stays false (the Reset button stays disabled) and the value is left out of the URL.

1use MaskowLabs\BasicComponents\Helpers\TableFilter;
2 
3public function filters(): array
4{
5 return [
6 /**
7 * default() pre-selects a value on mount. The same value is also used as
8 * the "except" value of the query string, so a table sitting on its
9 * default filter keeps a clean URL, and hasActiveFilters() stays false.
10 *
11 * Note: default() is typed as string. For a checkbox that should start
12 * out checked, pass '1'.
13 */
14 (new TableFilter())
15 ->type('select')
16 ->column('status')
17 ->label('Status')
18 ->default('active')
19 ->options([
20 ['value' => 'active', 'label' => 'Active'],
21 ['value' => 'pending', 'label' => 'Pending'],
22 ['value' => 'inactive', 'label' => 'Inactive'],
23 ]),
24 
25 (new TableFilter())
26 ->type('checkbox')
27 ->column('archived')
28 ->label('Show archived')
29 ->default('1'),
30 ];
31}

Manual Filter Handling

Not every filter maps to a simple column comparison. Mark such a filter with handleManually(): the built-in condition is skipped and only filterItemModifier() runs. Because nothing touches the column any more, the column() may be a virtual name that does not exist in the database.

filterItemModifier() is called for every active filter, so check the $column before you act.

1use Illuminate\Database\Eloquent\Builder;
2use MaskowLabs\BasicComponents\Helpers\TableFilter;
3 
4public function filters(): array
5{
6 return [
7 /**
8 * handleManually() stops the built-in where()/whereIn() from running.
9 * The column name becomes a plain identifier - it does not have to exist
10 * in the database at all. You build the condition yourself in
11 * filterItemModifier().
12 */
13 (new TableFilter())
14 ->type('select')
15 ->column('created')
16 ->label('Created')
17 ->placeholder('Any time')
18 ->handleManually()
19 ->options([
20 ['value' => 'today', 'label' => 'Today'],
21 ['value' => 'this_week', 'label' => 'This week'],
22 ['value' => 'this_month', 'label' => 'This month'],
23 ]),
24 
25 (new TableFilter())
26 ->type('checkbox')
27 ->column('without_orders')
28 ->label('Customers without orders')
29 ->handleManually(),
30 ];
31}
32 
33/**
34 * Runs for every active filter - both for the manual ones and, after the
35 * built-in condition has been applied, for the automatic ones.
36 */
37protected function filterItemModifier(Builder $query, string $type, string $column, mixed $value): Builder
38{
39 if ($column === 'created') {
40 return match ($value) {
41 'today' => $query->whereDate('created_at', today()),
42 'this_week' => $query->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]),
43 'this_month' => $query->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()]),
44 default => $query,
45 };
46 }
47 
48 if ($column === 'without_orders' && $value) {
49 return $query->doesntHave('orders');
50 }
51 
52 return $query;
53}

Filters in the URL

Search, sorting and every filter are bound to the query string, so a filtered table can be bookmarked and shared. Use as() to give a filter a nicer parameter name than its column.

1use MaskowLabs\BasicComponents\Helpers\TableFilter;
2 
3public function filters(): array
4{
5 return [
6 /**
7 * Without as(), the filter appears in the URL under its column name:
8 * ?category_id=3
9 *
10 * With as('category') it becomes:
11 * ?category=3
12 */
13 (new TableFilter())
14 ->type('select')
15 ->column('category_id')
16 ->label('Category')
17 ->as('category')
18 ->options([
19 ['value' => 1, 'label' => 'Hardware'],
20 ['value' => 2, 'label' => 'Software'],
21 ]),
22 ];
23}
24 
25/*
26 * The search term and the sorting are always in the query string:
27 *
28 * ?q=maskow&sort=name&dir=desc&category=2
29 *
30 * q -> $search (omitted while empty)
31 * sort -> $sortColumn
32 * dir -> $sortDirection
33 */

Session-Persisted Filters

Filters can survive a page change. Keep filters writes the current $activeFilters into the session, and mount() restores them on the next render of that same table class.

The session key is derived from the class name (filters. plus the MD5 of get_class($this)), so two different table components never overwrite each other — but two instances of the same class do share one slot.

1/**
2 * Session-persisted filters
3 * -------------------------
4 * The filter dropdown of <x-table.header> already ships with the three buttons
5 * that drive this, so you normally do not have to call any of it yourself:
6 *
7 * "Keep filters" -> rememberFilters()
8 * "Forget" -> resetSessionFilters()
9 * "Reset" -> resetTable()
10 */
11 
12// Writes the current $activeFilters into the session under
13// "filters." . md5(static::class) - so every table class has its own slot.
14$this->rememberFilters();
15 
16// true as soon as something was stored for this table class.
17$this->hasSessionFilters();
18 
19// Removes the stored filters AND calls resetTable().
20$this->resetSessionFilters();
21 
22// Puts every filter back to its default() (or null) and empties the search.
23// It does not touch the sorting or the current page.
24$this->resetTable();
25 
26/**
27 * The stored filters are read back in mount(). If you write your own mount(),
28 * call the parent - otherwise filters(), the defaults and the session restore
29 * never run:
30 */
31public function mount(?Team $team = null)
32{
33 $this->team = $team;
34 
35 parent::mount();
36}

Shaping the Query

queryModifier() is applied to every query and cannot be switched off by the user — the right place for eager loading and for conditions that guard the data.

The order inside getEntries() is fixed: search → filters → queryModifier() → orderBy → paginate($perPage).

1use Illuminate\Database\Eloquent\Builder;
2 
3/**
4 * Applied to every query, after the search and the filters, before the sorting
5 * and the pagination. Use it for eager loading, tenant scoping, soft deletes
6 * or anything else the user must not be able to switch off.
7 */
8protected function queryModifier(Builder $query): Builder
9{
10 return $query
11 ->with(['company', 'roles'])
12 ->where('team_id', auth()->user()->current_team_id)
13 ->whereNull('deleted_at');
14}

Pagination

getEntries() returns a LengthAwarePaginator, so rendering the links is a one-liner. Set the page size with $perPage. Changing a filter or sorting jumps back to page 1 automatically.

See the Pagination page for all options of the pagination view.

1{{-- TableComponent already uses Livewire's WithPagination trait and
2 getEntries() returns a LengthAwarePaginator, so all you have to do is
3 render the links. --}}
4 
5{{ $entries->links(view: 'basic-components::components.blade.pagination') }}
6 
7{{-- With options: --}}
8{{ $entries->links(
9 view: 'basic-components::components.blade.pagination',
10 data: [
11 'scrollTo' => '#sample-table',
12 'prevIcon' => 'chevron-left--micro',
13 'nextIcon' => 'chevron-right--micro',
14 ]
15) }}

Empty State

There is no built-in empty state — use @forelse and render a single row with a colspan when the paginator is empty.

Customer
E-Mail
Total
No entries found.
1@php
2 // In a real component this collection comes from $entries.
3 $entries = collect();
4@endphp
5 
6<x-table>
7 <x-table.thead>
8 <x-table.tr>
9 <x-table.th name="customer">Customer</x-table.th>
10 <x-table.th name="email">E-Mail</x-table.th>
11 <x-table.th name="total" centered>Total</x-table.th>
12 </x-table.tr>
13 </x-table.thead>
14 
15 <x-table.tbody>
16 @forelse($entries as $entry)
17 <x-table.tr wire:key="entry-{{ $entry->id }}">
18 <x-table.td>{{ $entry->name }}</x-table.td>
19 <x-table.td>{{ $entry->email }}</x-table.td>
20 <x-table.td centered>{{ $entry->total }}</x-table.td>
21 </x-table.tr>
22 @empty
23 <x-table.tr>
24 <x-table.td colspan="3" centered>
25 <span class="text-gray-500">@lang('No entries found.')</span>
26 </x-table.td>
27 </x-table.tr>
28 @endforelse
29 </x-table.tbody>
30</x-table>

Loading States

The markup brings its own loading feedback: <x-table> receives table--loading while a request is in flight, a sortable header cell receives cell--loading while its own sortBy() runs, and the search input gets table__header-search--loading with a spinner.

For the initial load, TableComponent implements placeholder() — mark the component as lazy and Livewire renders a table-shaped skeleton with as many rows as $perPage until the real table arrives.

1{{-- <x-table> already carries wire:loading.class="table--loading", and every
2 sortable <x-table.th> carries wire:loading.class="cell--loading" targeted at
3 its own sortBy() call. You get the loading feedback for free. --}}
4 
5{{-- Add your own indicator wherever you need one: --}}
6<div wire:loading.delay wire:target="search, activeFilters, sortBy, gotoPage">
7 <x-icon name="arrow-path--mini" class="spin" />
8 @lang('Loading ...')
9</div>
10 
11{{-- Lazy loading: TableComponent ships a placeholder() that renders a table
12 shaped skeleton with as many rows as $perPage. Just mark the component lazy: --}}
13<livewire:sample-table lazy />