Filtering
Lattice has two kinds of filter. Column filters compare a single column with an operator and live in that column’s header. Dedicated filters are named, table-level controls that own their own query logic. Both are sent to the table’s endpoint and applied by the data source.
TextColumn::make('name')->label('Name')->sortable()->filterable();NumberColumn::make('price')->label('Price')->sortable()->filterable();BooleanColumn::make('featured')->label('Featured')->filterable();TextColumn::make('updated_at')->label('Updated')->dateTime()->sortable();- actionsLabel:null
- bulkActions:[]
- data:[{"featured":true,"name":"Desk Lamp","price":"49.00","updated_at":"2026-05-30 09:15:00"},{"featured":false,"name":"Office Chair","price":"189.00","updated_at":"2026-06-02 14:40:00"},{"featured":true,"name":"Monitor Stand","price":"75.50","updated_at":"2026-06-08 08:05:00"}]
- defaultPerPage:25
- emptyLabel:null
- endpoint:null
- filters:[]
- layout:null
- lazy:false
- pagination:{"currentPage":null,"from":1,"hasMore":false,"lastPage":null,"mode":"none","nextPage":null,"perPage":null,"to":3,"total":3}
- perPageOptions:[]
- pinnableColumns:false
- query:{"filters":[],"mode":null,"page":1,"perPage":25,"search":"","sorts":[],"tableFilterIndicators":[],"tableFilters":{}}
- queryKey:null
- resizableColumns:false
- resizeIndicator:false
- searchable:false
- striped:false
- syncQuery:false
- toolbar:[]
- align:"start"
- badge:null
- copyable:false
- date:null
- hiddenByDefault:false
- label:"Name"
- link:null
- multiple:null
- options:[]
- pinned:null
- sortable:true
- toggleable:false
- width:"md"
- align:"end"
- compact:false
- copyable:false
- hiddenByDefault:false
- label:"Price"
- maximumFractionDigits:null
- minimumFractionDigits:null
- options:[]
- pinned:null
- sortable:true
- toggleable:false
- unit:null
- width:"sm"
- align:"start"
- hiddenByDefault:false
- label:"Featured"
- options:[]
- pinned:null
- sortable:false
- toggleable:false
- width:"xs"
- align:"start"
- badge:null
- copyable:false
- date:{"dateStyle":"medium","timeStyle":"medium"}
- filter:null
- hiddenByDefault:false
- label:"Updated"
- link:null
- multiple:null
- options:[]
- pinned:null
- sortable:true
- toggleable:false
- width:"md"
Column filters
Section titled “Column filters”->filterable() adds a filter for the column. The available operators come from the column’s filter
type, which is inferred from its display modifiers:
| Column | Filter type | Operators |
|---|---|---|
TextColumn (default) |
Text |
contains, starts_with, ends_with, =, ≠, empty, filled |
NumberColumn |
Number |
=, ≠, >, ≥, <, ≤, empty, filled |
TextColumn->date() |
Date |
=, before, after, empty, filled |
BooleanColumn |
Boolean |
=, empty, filled |
TextColumn::make('name')->filterable(); // text filterNumberColumn::make('price')->filterable(); // number filterTextColumn::make('created_at')->date()->filterable(); // date filterFilter types and operators
Section titled “Filter types and operators”Operators are the shared Op enum (Lattice\Core\Enums\Op) — the
same vocabulary used by form conditions. Each FilterType
offers a sensible default set and a default operator (text defaults to contains, the rest to =).
Narrow the offered operators, or change the default, by passing arguments to ->filterable():
use Lattice\Core\Enums\Op;
// Only allow exact / negated matches, defaulting to "equals":TextColumn::make('sku')->filterable(Op::Equals, [Op::Equals, Op::NotEquals]);A column’s filter capability serializes as a ColumnFilter ({ type, operators, defaultOperator, control, options, multiple, searchable, clauseOptions }) so the React filter control
knows which inputs and operators to render. Open the Tree tab on the example above to see it.
Filtering by a fixed set of options
Section titled “Filtering by a fixed set of options”When a column holds one of a known set of values (a status, a type), ->filterOptions() renders a
dropdown in the column header instead of the operator input — the same control used by the
dedicated SelectFilter. A single selection matches with =; pass
multiple: true to match any of the chosen values with in.
use Lattice\Table\Columns\TextColumn;
// associative value => labelTextColumn::make('status')->filterOptions([ 'draft' => 'Draft', 'active' => 'Active', 'archived' => 'Archived',]);
// or an enum (labels from the HasLabel contract, else the humanised case name)TextColumn::make('status')->filterOptions(Status::class);
// match any of several values:TextColumn::make('status')->filterOptions([...], multiple: true);filterOptions() implies filterable(), sets the column’s filter control to filter.select, and
restricts the offered operators to = / ≠ (or in / not in when multiple). The selected value
rides the same column-filter clause as any other operator filter, so the data source applies it with no
extra wiring.
Options can also come from an OptionSource (e.g. an Eloquent relation) instead of a fixed list — the
same source the Select field uses, so forms and tables resolve options the
same way:
use Lattice\EloquentOptions;
TextColumn::make('author_id') ->label('Author') ->filterOptions(EloquentOptions::make(Author::class)->label('name'));
// fetch options as the user types instead of shipping them all up front:TextColumn::make('author_id') ->filterOptions(EloquentOptions::make(Author::class)->label('name'), searchable: true);Use ColumnFilterOption when one dropdown choice should emit an operator other than the select
default. This keeps the request in the standard column-filter clause format, so the data source still
uses the same operator pipeline:
use Lattice\Core\Enums\Op;use Lattice\Table\Columns\ColumnFilterOption;
BooleanColumn::make('verified')->filterOptions([ ColumnFilterOption::clause('Yes', 'yes', Op::Equals, 'true'), ColumnFilterOption::clause('No', 'no', Op::Equals, 'false'), ColumnFilterOption::clause('Unset', 'unset', Op::Empty),]);
TextColumn::make('updated_at')->date()->filterOptions([ ColumnFilterOption::range('This month', 'this-month', '2026-06-01', '2026-06-30'),]);Dedicated filters
Section titled “Dedicated filters”Per-column filters compare a single column with an operator. Dedicated filters are named,
table-level controls rendered in the header’s filter dropdown — each owns its own control and query
logic, and isn’t tied to a column. Declare them by overriding filters():
use Illuminate\Database\Eloquent\Builder;use Lattice\Table\Filters\DateRangeFilter;use Lattice\Table\Filters\SelectFilter;use Lattice\Table\Filters\TernaryFilter;use Lattice\Table\Filters\ToggleFilter;
public function filters(): array{ return [ SelectFilter::make('status') ->options([ SelectFilter::option('Draft', 'draft'), SelectFilter::option('Active', 'active'), ]) ->multiple(), // match any selected → whereIn
TernaryFilter::make('featured') // yes / no / all ->trueLabel('Featured') ->falseLabel('Not featured'),
DateRangeFilter::make('created_at'), // from / until, inclusive
ToggleFilter::make('high_value') // simple on/off query ->query(fn (Builder $query): Builder => $query->where('price', '>', 1000)), ];}| Filter | Control | Applies |
|---|---|---|
SelectFilter |
dropdown | where (single) or whereIn (->multiple()); ->attribute() to remap |
TernaryFilter |
yes / no / all | boolean where, or custom ->queries(true: …, false: …) |
DateRangeFilter |
from / until | inclusive whereDate on each provided bound |
ToggleFilter |
toggle | your ->query() closure when on, or boolean where by default |
The filter dropdown opens from the icon button in the trailing header cell, where the actions heading would otherwise sit. Active dedicated and column filters share one bar of individually removable chips with a single Reset all that clears them both.
A SelectFilter’s options can come from an OptionSource instead of a fixed list, and
->searchable() fetches them as the user types rather than shipping the whole list up front. The
lookup is a search sub-request to the table endpoint with a namespaced _target —
filter:<key>.<field> for a dedicated filter’s field, column:<key> for a searchable column
filter — so filter keys and dot-keyed relation columns can never collide:
use Lattice\EloquentOptions;
SelectFilter::make('author') ->optionsFrom(EloquentOptions::make(Author::class)->label('name')) ->searchable();Custom dedicated filters
Section titled “Custom dedicated filters”For anything beyond the built-ins, extend the abstract Filter class. A filter’s schema() returns
normal form fields, so field modifiers like ->rules() are reused directly. Lattice validates those
rules before apply() receives the sanitized FormData.
use Illuminate\Database\Eloquent\Builder;use Lattice\Form\Components\NumberInput;use Lattice\Form\FormData;use Lattice\Table\Attributes\AsFilter;use Lattice\Table\Filters\Filter;
#[AsFilter('filter.price-band')]final class PriceBandFilter extends Filter{ public function schema(): array { return [ NumberInput::make('min', 'Min')->rules(['numeric', 'min:0']), NumberInput::make('max', 'Max')->rules(['numeric', 'min:0']), ]; }
public function apply(Builder $builder, FormData $data): void { if ($data->has('min')) { $builder->where('price', '>=', $data->float('min')); }
if ($data->has('max')) { $builder->where('price', '<=', $data->float('max')); } }
public function indicator(FormData $data): string|array|null { return null; }}Returning null from indicator() uses the default chips built from the active schema fields. Return
a string, an array of strings, or ['Label' => $value] pairs when you need custom indicator text. If
schema() returns an empty array, the client renders the filter as a simple toggle with a value
field; use ToggleFilter unless you need a custom subclass.
How values reach the server
Section titled “How values reach the server”Dedicated filter values post as objects under the filter key: tf[status][value]=active,
tf[status][value][]=… for multi-select, tf[created_at][from]=… / [until] for a range, and
tf[high_value][value]=1 for an empty-schema toggle. Unknown filter keys return a 422 response.
Known filters whose values fail their field rules are ignored instead of being applied.
After validation, the data source calls each active filter’s apply() method.
ToggleFilter::query() closures run server-side with utility injection — type-hint Builder to
receive the query. See Closure evaluation for the resolver contract.
Syncing filters to the URL
Section titled “Syncing filters to the URL”A table can restore its filters, sort, search, and page from the page URL on load, and keep the URL
updated as the user changes them — so a filtered view survives a refresh and can be shared as a link.
It’s opt-in: override syncsQueryToUrl() on the definition.
final class ProductsTable extends EloquentTableDefinition{ public function syncsQueryToUrl(): bool { return true; }}The URL params are the same names the table’s endpoint already accepts — filter, sort, q,
page, per_page, and tf[...] for dedicated filters — so a synced table’s shared link is
human-readable and doubles as the endpoint’s own query string. page is omitted while on the first
page and per_page while it matches the definition’s default, so a clean URL stays clean.
Unlike the endpoint, a synced page render is tolerant: an unknown filter field, an unsortable sort
key, an unknown operator, or an unknown tf key is silently dropped instead of rejected, and the page
renders the rest of the query normally. This keeps a stale or hand-edited URL from breaking the page —
only the endpoint itself still 422s on an invalid request.
More than one synced table on a page
Section titled “More than one synced table on a page”At most one synced table (or board) may own
the unprefixed params on a given page. When a page has more than one, give every synced component but
one a urlQueryKey() — it nests that component’s params under one bracketed key instead:
public function urlQueryKey(): ?string{ return 'products';}With urlQueryKey('products'), the table’s params become products[filter], products[sort],
products[q], products[page], products[per_page], and products[tf][status][value]. A synced
component only ever reads and writes its own scope, so unprefixed params — and another component’s
prefixed ones — are left untouched.