Build Custom Table Filters in Filament v4

Build custom table filters in Filament v4: pair a form schema with a query() callback, add a date range, wire indicators, and control when filters apply.

Steven Richardson
Steven Richardson
· 8 min read

SelectFilter and TernaryFilter handle the easy cases: a status dropdown, a boolean toggle. But every real admin panel hits the ceiling fast. You need orders over a certain total, a created-between date range, a dropdown that reads from a relationship — filters the built-ins don't ship. The docs show the pieces across a few pages, but nobody wires them into one coherent build.

This walkthrough builds a custom table filter in Filament v4 from scratch, against an OrderResource. A numeric threshold, a multi-input date range, active indicators that clear one field at a time, and the live-versus-deferred control that changed in v4. By the end you'll know the whole model, which is smaller than it looks: a schema of form fields plus a query() callback.

Add a filter to the table's filters array#

Filters live in the table's filters() array. The base Filter::make() renders a checkbox — when it's ticked, the query() callback runs and scopes the table; when it's unticked, the clause is removed. Start with the simplest useful version: a checkbox that shows only high-value orders.

use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

public static function table(Table $table): Table
{
    return $table
        ->columns([
            // ...
        ])
        ->filters([
            Filter::make('high_value')
                ->label('High value (£1,000+)')
                ->query(fn (Builder $query): Builder => $query->where('total', '>=', 1000)),
        ]);
}

That's the entire contract for a boolean filter. If you'd rather render a toggle than a checkbox, chain ->toggle(). This is the base you'll extend — everything that follows is the same Filter::make() with more fields hung off it. If you don't have a panel and resource in place yet, the Filament v4 zero-to-production dashboard guide covers that groundwork first.

Define the filter's form schema#

The checkbox is just the default field. Pass a schema() and Filament renders your fields instead — filters are built on the same form components you use everywhere else, so any field works. Swap the hard-coded £1,000 for a number the user types:

use Filament\Forms\Components\TextInput;
use Filament\Tables\Filters\Filter;

Filter::make('min_total')
    ->schema([
        TextInput::make('min_total')
            ->label('Minimum total')
            ->numeric()
            ->prefix('£'),
    ])

The field name — min_total — is the key you'll read back in $data. Because we supplied a schema, the default checkbox is gone; the filter dropdown now shows the number input. Drop in a Select for a status picker, a Toggle, a DatePicker — whatever the constraint needs. If you need a field the framework doesn't ship, building a custom Filament v4 form field plugs straight into a filter schema. For the common "filter by a related model" case, don't hand-roll it — reach for the purpose-built SelectFilter::make('customer')->relationship('customer', 'name') instead.

Apply the constraint in the query callback#

The schema collects input; query() applies it. The callback receives the Eloquent builder and a $data array keyed by your field names. Read the value and wrap the clause in when() so it only runs when the field actually has a value:

use Filament\Forms\Components\TextInput;
use Filament\Tables\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;

Filter::make('min_total')
    ->schema([
        TextInput::make('min_total')
            ->label('Minimum total')
            ->numeric()
            ->prefix('£'),
    ])
    ->query(function (Builder $query, array $data): Builder {
        return $query->when(
            $data['min_total'],
            // The value is passed straight to the closure — no need to re-read $data.
            fn (Builder $query, $value): Builder => $query->where('total', '>=', $value),
        );
    })

when()'s first argument is the condition. An empty input arrives as null, so the clause is skipped and the table stays unscoped. The second argument to the inner closure is that same value, which reads cleaner than reaching back into $data. One sharp edge worth knowing now: when() tests truthiness, and in PHP the string '0' is falsy — a literal zero threshold won't apply. That's fine for "minimum total", but if zero is a meaningful input elsewhere, guard with filled($data['min_total']) instead.

Build a multi-input date range filter#

A filter isn't limited to one field. Add two DatePickers to the same schema and you have a from/until range inside a single filter — the pattern people reach for most. Guard each end independently so from-only, until-only, and both all behave:

use Filament\Forms\Components\DatePicker;
use Filament\Tables\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;

Filter::make('created_at')
    ->schema([
        DatePicker::make('created_from'),
        DatePicker::make('created_until'),
    ])
    ->query(function (Builder $query, array $data): Builder {
        return $query
            ->when(
                $data['created_from'],
                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
            )
            ->when(
                $data['created_until'],
                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
            );
    })

Reach for whereDate() rather than where() here — it compares the date part and ignores the time component, so an order created at 4pm today still matches an "until today" bound. Want the range pre-filled? Chain ->default(now()) on either picker. By default the two inputs stack in the dropdown; the filter layout options will sit them side by side if the panel has room.

Add active indicators and control when filters apply#

Two loose ends remain. A custom-schema filter shows no indicator by default, which means the active-filter badge count silently ignores it and the user can't clear it from the indicator bar. Fix that with indicateUsing(), returning one Indicator per field:

use Carbon\Carbon;
use Filament\Forms\Components\DatePicker;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\Indicator;

Filter::make('created_at')
    ->schema([
        DatePicker::make('created_from'),
        DatePicker::make('created_until'),
    ])
    // ->query(...) as above
    ->indicateUsing(function (array $data): array {
        $indicators = [];

        if ($data['created_from'] ?? null) {
            $indicators[] = Indicator::make('From ' . Carbon::parse($data['created_from'])->toFormattedDateString())
                ->removeField('created_from');
        }

        if ($data['created_until'] ?? null) {
            $indicators[] = Indicator::make('Until ' . Carbon::parse($data['created_until'])->toFormattedDateString())
                ->removeField('created_until');
        }

        return $indicators;
    })

removeField() ties each chip to the field it clears, so dismissing "From 12 Jun 2026" resets only created_from and leaves the until bound intact. For a single-field filter you can return a plain string instead of an array, or null when nothing is active.

The second loose end is timing, and it's the v4 change that trips people up. In v4, filters are deferred by default: the table doesn't re-query until the user clicks Apply. (In v3 they were live — every keystroke hit the database.) So if you came here wondering how to stop a filter firing on every change, you don't have to; that's already the default. To go the other way and make filters live, opt out on the table:

public static function table(Table $table): Table
{
    return $table
        ->filters([
            // ...
        ])
        ->deferFilters(false);
}

Customise the Apply button through filtersApplyAction():

use Filament\Actions\Action;

->filtersApplyAction(
    fn (Action $action) => $action->label('Apply filters'),
)

One bonus you get for free: filter state rides in the URL query string by default, so a filtered view is shareable and bookmarkable. If you want it to survive across visits instead, add persistFiltersInSession(). The live-versus-deferred trade-off is the same one behind dependent dropdowns with live() and reactive fields — server round-trips buy you reactivity, deferring buys you fewer queries.

Gotchas and Edge Cases#

The scoped-where behaviour catches people modifying the base query. By default, whatever you do in query() is wrapped in a nested where() so it can't clash with another filter's orWhere(). That's the right default, but it means query() can't remove a global scope — for that, use baseQuery(), which hands you the unwrapped builder.

Relationship-backed filters are an N+1 trap. If your query() or your indicateUsing() closure reads a relationship — resolving a customer name to render a chip, say — you can fan out into a query per row. Eager-load the relationship on the table's base query, and lean on Eloquent strict mode to catch the N+1 early before it ships. For a straight relationship dropdown, SelectFilter's relationship() handles the join for you and sidesteps the problem entirely.

Skipping indicateUsing() on a custom filter is the most common miss. Without it the filter still works, but it won't count toward the active-filter badge and users can't clear it from the indicator bar — they have to reopen the dropdown and blank the fields by hand. Always add one for multi-field filters.

Exporting respects the filters. The ExportAction and bulk export run against the currently scoped query, so "export what I'm looking at" works with no extra wiring — see Filament v4's built-in import and export actions for that flow. If your indicators look right but the export ignores them, the usual cause is a filter touching baseQuery() rather than query().

Wrapping Up#

The whole model is smaller than it first appears: a Filter::make(), a schema() of ordinary form fields, and a query() callback that reads $data and applies when()-guarded clauses. Date ranges, indicators, live-versus-deferred — all of it is composition on top of those three pieces. Build one custom filter and the rest are variations.

From here, the natural next steps are wiring import and export actions so users can pull the filtered set out as CSV, or if you're still assembling the panel, working through the zero-to-production dashboard guide.

FAQ#

How do I create a custom filter in Filament v4?

Add a Filter::make('name') to your table's filters() array, give it a schema() of form fields, and a query() callback. The base Filter renders a checkbox; the moment you pass a schema, Filament renders your fields instead. Inside query() you receive the Eloquent builder and a $data array of the submitted values, and you apply whatever where clauses you need. That pairing — schema plus query callback — is the entire pattern.

How do I filter a Filament table by a date range?

Put two DatePicker fields in one filter's schema, created_from and created_until, and in the query() callback apply a whereDate('created_at', '>=', ...) for the from value and a whereDate('created_at', '<=', ...) for the until value. Wrap each in when() so a one-sided range still works. Use whereDate() rather than where() so the time component doesn't exclude same-day rows.

How do I read filter values in the query callback?

The second argument to the query() closure is a $data array keyed by the field names in your schema, so a DatePicker::make('created_from') shows up as $data['created_from']. Wrap each clause in when($data['field'], ...) so it only applies when the user actually set that field. The value is handed to the inner closure as its second argument, which is cleaner than re-reading $data inside the clause.

How do I show and clear active filter indicators in Filament?

Custom-schema filters don't render an indicator automatically, so add indicateUsing(). Return a string for a simple case, or an array of Indicator objects for a multi-field filter. Call removeField('created_from') on each Indicator so removing that chip resets only its field rather than the whole filter. Without an indicator, the filter also won't count toward the table's active-filter badge.

How do I stop a Filament filter from running on every change?

In Filament v4 you don't need to — filters are deferred by default and only apply when the user clicks Apply. That's a change from v3, where filters were live and re-queried on every change. If you actually want live filtering, call deferFilters(false) on the table; to adjust the Apply button's label or styling, use filtersApplyAction().

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.