Filters
A Filter narrows the board's query from the toolbar. The fields are Filament's own form components (its Select, searchable or not, its DatePicker, its Toggle), rendered through a schema on the component, and the same Filter class with the same methods is in Filament Gantt and Filament Spreadsheet, so a filter written for one works in the others.
use Packstub\Kanban\Filter;
->filters([
Filter::select('owner_id')->label('Owner')->options(fn () => User::pluck('name', 'id')->all()),
Filter::select('tags')->multiple()->options(Tag::pluck('name', 'id')->all()),
Filter::boolean('is_hot')->label('Hot deal'),
Filter::toggle('mine')->label('Assigned to me')->query(fn (Builder $query) => $query->where('owner_id', auth()->id())),
Filter::dateRange('due_at'),
])
Every filter is applied on the server only: whatever the browser or the URL sends is checked again. A plain select takes one of its options and nothing else, a multiple one drops unknown values one by one, a searchable select takes any key (its values come from a search, the query decides what matches), a date range takes real calendar dates only.
Select
Filter::select('owner_id') (or Filter::make(), the same) is one of the options. By default a chosen value narrows the query with where(<name>, <value>); give your own with query(), which gets the value as a string, the option's key as the browser sends it ("3" for User::pluck('name', 'id')). Options given as a closure are resolved once per request, and on a refresh only once a value is set.
Filter::select('owner_id')->label('Owner')->options(fn () => User::pluck('name', 'id')->all()),
Filter::select('due')->options(['overdue' => 'Overdue', 'week' => 'Due this week'])
->query(fn (Builder $query, string $value) => match ($value) {
'overdue' => $query->where('due_at', '<', now()),
'week' => $query->whereBetween('due_at', [now(), now()->endOfWeek()]),
}),
Several at once
multiple() lets the user pick several options; the default narrows with whereIn(<name>, <values>), a query() closure gets the chosen values as a list of strings, in the options' order. A value outside the options is dropped on its own, the others still apply.
Filter::select('tags')->label('Tags')->multiple()->options(Tag::pluck('name', 'id')->all())
->query(fn (Builder $query, array $values) => $query->whereHas('tags', fn ($q) => $q->whereKey($values))),
Searchable
searchable() lets the user type to find an option, for a long list (every user, every customer). With preload() (the default) the options are sent with the board and the search runs on the server over their labels, or through getSearchResultsUsing(); preload(false) sends nothing until the user types. getOptionLabelUsing() names a value that is not among the listed options (one restored from the URL), optionsLimit() caps a list or a search (50 by default). The select is Filament's own Select form component, so it looks and behaves exactly like one in a form; the search goes through the component's schema, nothing on the page re-renders. It also caps the values a searchable multiple() filter accepts in one request and the labels looked up for them: a searchable select takes any key, so the list is bounded where a listed one is bounded by its options. A search term is read up to 200 characters.
Filter::select('customer_id')->label('Customer')->searchable()->preload(false)
->getSearchResultsUsing(fn (string $search) => Customer::where('name', 'like', "%{$search}%")->limit(50)->pluck('name', 'id')->all())
->getOptionLabelUsing(fn (string $value) => Customer::find($value)?->name),
Boolean
Filter::boolean('is_hot') is a Yes / No select (Filament's native select). By default it narrows <name> to true or false; a query() closure gets a bool.
Filter::boolean('is_hot')->label('Hot deal'),
Filter::boolean('assigned')->query(fn (Builder $query, bool $on) => $on ? $query->whereNotNull('owner_id') : $query->whereNull('owner_id')),
Toggle
Filter::toggle('mine') is one toggle switch with the filter's label and no options: "Overdue", "Assigned to me". There is no default query, so query() is required (checked on the first render, not when someone clicks); it gets true when the toggle is on and is not called at all when it is off.
Filter::toggle('mine')->label('Assigned to me')
->query(fn (Builder $query) => $query->where('owner_id', auth()->id())),
Date range
Filter::dateRange('due_at') is a From and an Until date picker; either side may be left open. By default the range narrows <name> to the days between, the last day included, without wrapping the column in a function (its index stays in use); a query() closure gets ['from' => ?string, 'until' => ?string] as Y-m-d. The pickers display config('packstub-kanban.date_format') (M j, Y by default) and start their weeks on first_day_of_week.
Filter::dateRange('due_at')->label('Due'),
Filter::dateRange('created_at')->query(fn (Builder $query, array $range) => $query
->when($range['from'], fn ($q, $from) => $q->where('created_at', '>=', $from))
->when($range['until'], fn ($q, $until) => $q->where('created_at', '<', Carbon::parse($until)->addDay()))),
Where the filters are shown
filtersLayout() on the board, or filters_layout in config/packstub-kanban.php for every board:
use Packstub\Kanban\Enums\FiltersLayout;
->filtersLayout(FiltersLayout::Dropdown) // behind a funnel button, with the count of active filters (the default, as a table's)
->filtersLayout(FiltersLayout::Toolbar) // inline in the toolbar, after the search box
->filtersLayout(FiltersLayout::AboveContent) // on a row of their own between the toolbar and the columns
The dropdown and the row have a Reset link once any filter is active. Without filters nothing is rendered, not even the funnel.
Search and filters in the URL
The search and the active filters are kept in the page's query string (?search=acme&filters[owner_id][0]=3&filters[owner_id][1]=7&filters[mine]=1&filters[due_at][from]=2026-01-01), replaced in place as they change, so a filtered board can be bookmarked, shared and reloaded; an untouched board keeps a clean URL. The URL is trusted no more than the browser is: a filter the board does not define is ignored, a value outside the options is dropped in the browser and on the server, a date that is not a calendar day (2026-02-30) never reaches the query. The page is drawn filtered from the start, with no second request; a board drawn later (lazy, deferred) reads the URL in the browser and refreshes once. persistInUrl(false) leaves the URL alone.
The keys are the same for every board, so one URL-persisted board per page: with two boards on a page, or a page that uses ?search= for something else, persistInUrl(false) on the others. A board only rewrites its own keys (search when it is searchable, filters[...] for the filters it defines) and leaves every other parameter alone.
The fluent API
| Method | Default | |
|---|---|---|
select(string $name), make() |
One option; the attribute the default query narrows, and the key in the URL. | |
boolean(string $name) |
A Yes / No select; the query gets a bool. | |
toggle(string $name) |
One toggle switch; query() is required and gets true. |
|
dateRange(string $name) |
From / Until pickers; the query gets ['from' => ?string, 'until' => ?string]. |
|
label(string|Closure|null) |
from the name | owner_id reads "Owner", due_at "Due". |
options(array|Closure) |
[] |
[value => label]; the only values a plain select accepts. |
query(Closure) |
where / whereIn / a range |
fn (Builder $query, $value): a string, a list of strings for multiple(), a bool, true, or the range. |
multiple(bool = true) |
false |
Several options at once. |
searchable(bool|Closure = true) |
false |
Type to find an option. |
preload(bool = true) |
true |
Send the options with the board, or wait for a search. |
getSearchResultsUsing(?Closure) |
the labels | fn (string $search): array. |
getOptionLabelUsing(?Closure) |
the options | fn (string $value): ?string, for a value outside the listed options. |
optionsLimit(int) |
50 |
The most options a list or a search shows, and the most values a searchable multiple() filter accepts. |