Skip to content

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();

->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 filter
NumberColumn::make('price')->filterable(); // number filter
TextColumn::make('created_at')->date()->filterable(); // date filter

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.

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 => label
TextColumn::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'),
]);

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();

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.

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.

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.

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.