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 _targetfilter:<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.