List Filters
Lists can be filtered using dropdown filters (tableFilters). Dropdown filters allow users to narrow results by selecting a value (e.g. year, language).
In addition, every list gets Excel-style column filters automatically — see Column Filters below.
Column Filters (Excel-style)
Every filterable column shows a funnel icon in its header (revealed on header hover, always visible while active). Clicking it opens a popover where the user types a filter expression; the filter is applied on Enter or via the Apply button. This is a single generic feature in NoerdList + the list views — no per-list configuration or per-module duplication.
A list in grid mode has no table header to hang the funnels on and renders the same popovers as labeled buttons in a control bar above the cards instead (which also carries the grid sort dropdown).
Operator syntax
A filter expression may start with a comparison operator: >=, <=, >, <, =, != (<> is accepted as !=). Without an operator the default depends on the column type:
| Column type | Popover UI | With operator | Without operator |
|---|---|---|---|
text (default) | Text input | =rot exact, !=rot not equal, >m string comparison | rot → LIKE %rot% (wildcards in the value are escaped) |
number, currency | Text input | >0, <=10, !=5 (comma decimals accepted: >=2,5) | Exact match |
date, datetime | Text input | >=2026-01-01 (also German format 17.07.2026) | That exact day (whereDate =) |
bool | All / Yes / No buttons | — | — |
badge/select (with options) | All, (Empty), (Not empty) + one button per option | — | Exact match on the option value |
every type except bool | Empty / Not empty buttons | = alone → empty, != / <> alone → not empty | — |
Invalid input (non-numeric value on a number column, unparseable date, a comparison operator such as >= without value) is silently ignored — the filter is a no-op, never an error.
Empty / Not empty
Like Excel's "(Blanks)", every column with a filter popover — text, number, currency, date, datetime, badge/select, JSON paths and relation paths — can be narrowed to empty or set values. The popover of a text input column shows two buttons Empty and Not empty above the input; a picklist/badge popover lists (Empty) and (Not empty) right after "All". Boolean columns keep their All / Yes / No buttons.
There is no extra state: the buttons write a bare operator as the expression, so the URL (?cf[field]==) and the session stay the same format as every other filter.
| Expression | Meaning |
|---|---|
= | Empty |
!= or <> | Not empty |
- What counts as empty:
NULL— and additionally the empty string''for string-like targets:text,badgeandselectcolumns and every JSON path. Realdate,datetime,number,currencyandboolcolumns are checked forNULLonly (comparing a MySQLDATEcolumn with''is not portable). On a JSON path a missing key and a JSONnullboth count as empty. - Relation paths: "Empty" also matches rows WITHOUT a related record (
whereDoesntHave(relation, value not empty)); "Not empty" requires a related record with a set value (whereHas(relation, value not empty)). - Chips: the header chip shows the translated "Empty" / "Not empty" instead of
=/!=, and the popover input stays blank while such a filter is active.
Which columns are filterable
A column is filterable when it is declared in the list YAML columns, is not action, and resolves to one of three things:
- a real column on the model's table (the same rule as sorting)
- a path into a JSON-cast column (e.g.
custom_attributes.sap_number): the segment before the first dot must be a real table column that the model casts to an array/object — the filter then applies through the JSON arrow operator (custom_attributes->sap_number). A JSON path has no DB schema type, so its filter type comes from the list column's explicittype:/optionsor the paired detail picklist, withtextas the fallback - a path through Eloquent relations (e.g.
defaultDeliveryAddress.locality): every segment before the last dot must be a public no-argument method returning a relation (nested paths work, exactly likewhereHas('a.b')), and the last segment a real column on the related table. The filter applies as awhereHas()subquery on that column — a join would collide with the base table's column names. The filter type comes from the list column's explicittype:, else from the related table's schema, withtextas the fallback
JSON paths take precedence over relation paths. A path that resolves to neither (a YAML typo, a method that is not a relation, a missing column) is silently dropped: no funnel, no filter, never an error. Dotted fields stay unsortable and unsearchable in every case — only filtering understands them. Lists that build a fully custom query (never calling listQuery()) show no funnels and apply no column filters.
Behavior
- Multiple column filters combine with AND — and stack with the search field and the header
listFilters(when the list applies them, see How Filters Work) - Setting or clearing a filter resets pagination to page 1
- Filters persist per component in the session (
listColumnFilters.{component}), like sorting - Filters are mirrored into the URL as
?cf[column]=expression— a shared link reproduces the exact view. On mount the URL wins over the session state. The active list view (?view=, includingdefault) and the filters are written into the URL on initial page load by the url-sync script in the list Blade (Livewire#[Url]bindings only write on updates); afterwards Livewire keeps them in sync. Only the page-level list writes the URL — modal, compact and picker lists never do - Compact/embedded and minimal lists render no funnels and never apply column filters (a session filter must not invisibly hide rows of an embedded widget)
- The header "Clear all filters" button (
clearAllListFilters()) clears the column filters too; each popover also offers a per-column clear - CSV export respects active column filters when the export query builds on
listQuery()
Header chips
Every active column filter renders as a chip next to the list title, so the user always sees WHICH field is filtered by WHICH value — e.g. PLZ: 95028 ✕. Each chip carries its own ✕ that clears exactly that filter (clearColumnFilter()). The global "Clear all filters" ✕ only appears when more than one chip is active or header listFilters are set on top — with a single chip its own ✕ suffices.
The chip resolves both parts for display via NoerdList::activeColumnFilterChips():
- Label: the column's translated
labelfrom the list YAML (falls back to the field name) - Value: bool columns show Yes/No instead of
1/0; picklist/badge columns show the translated option label (from the list column's ownoptionsor the paired detail YAML, same resolution as the badge cells); everything else shows the expression as typed (>=10,=Rot, …)
The type resolution (explicit YAML type → DB schema type) mirrors applyColumnFilters(), so a chip always describes the filter the query actually applies.
Header layout (title row + filter row)
The generic list header is two rows at every viewport width — and neither of them ever wraps:
- The title row holds the list title with its record count (and the view switcher when the list ships several YAML views) on the left, and every button on the right: the
style: secondaryactions, CSV export and the primary "New …" actions.x-noerd::modal-titledraws the grey separator below it. - The filter row renders only when something belongs in it — a search field, header filters or active-filter chips, or registry list actions (e.g. a module's layout and object managers). Pagination alone never opens it. It has three zones:
- the search field, left, at a fixed width (
w-40 sm:w-56), OUTSIDE the scrolling strip; - the filters — YAML picklist/date filters, one chip per active column filter, the clear-all button — inside a horizontally scrolling strip (
flex-1 min-w-0 overflow-x-scroll); - on the right, the registry list actions followed by the pagination summary (
1-50 of 150) with icon-only previous/next buttons — the same partial the footer renders (noerd::components.table.list-pagination-nav).
- the search field, left, at a fixed width (
The strip is built like the quick-menu: every control is shrink-0, so an overflowing filter set scrolls rather than squeezing, wrapping or collapsing, and the scrollbar track is permanently reserved so nothing shifts when scrolling becomes possible. Popovers are safe inside it (the picklist filter is a native <select>, the date dropdown anchors its panels with x-anchor.fixed).
There is no drawer, no funnel button and no breakpoint-specific stacking at any width. What a list header renders is resolved ONCE by NoerdList::headerControls(); the header rows and x-noerd::modal-title read that — never re-derive "does this list have a search field / a secondary action" from $listSettings at a call site. It is a single generic feature of list-header.blade.php: never rebuild a list header per module and never position header controls with JavaScript.
Architecture
- Expression parsing + query application:
Noerd\Services\ColumnFilterParser(fixed operator set, values only ever bound as parameters — user input never reaches SQL text) - State + whitelist:
NoerdList::$listColumnFilters,setColumnFilter(),clearColumnFilter(),filterableColumnFields(),applyColumnFilters()(hooked insidelistQuery()) - Header UI:
noerd::components.table.column-filter, included fromtable-sort.blade.php; active-filter chips:NoerdList::activeColumnFilterChips(), rendered innoerd::components.table.list-header - Header rows:
noerd::components.table.list-header(title row + filter row) includinglist-controls-secondaryandlist-controls-primary(title row),list-search,list-filters,list-controls-registryandlist-pagination-nav(filter row);NoerdList::headerControls()resolves which of them exist. A list host with its own custom header slot gets the one-rowlist-controlsinjected byx-noerd::modal-titleinstead
How Filters Work
- A component defines filter options via
tableFilters()(computed property) - The
list-header.blade.phptemplate renders the filter UI (dropdowns) - When a user selects a filter value, it is stored in
$listFiltersand persisted in the session (storeActiveListFilters()) - The component applies the filters to its query with
$this->applyListFilters($query)— or with custom logic where a dropdown value has to be transformed first
listQuery() applies search, sort and the Excel-style column filters, but NOT the header listFilters. A list that declares header filters therefore overrides listData() and calls applyListFilters() on the builder (see Using the Filter in a Component).
applyListFilters() only touches whitelisted columns (see Security); Picklist filters become where(column, value), ShowFrom/ShowUntil filters become >= / <= on the date columns of the ShowFromFilterTrait.
Defining a Filter
Each filter is defined by a method following the naming convention get{Name}ListFilter(). The method must return an array with these keys:
| Key | Description |
|---|---|
label | Display label for the dropdown |
column | Database column to filter on |
type | Picklist (dropdown, default), ShowFrom or ShowUntil (date-range dropdowns provided by ShowFromFilterTrait) |
options | Associative array of value => label pairs |
Example:
protected function getCategoryListFilter(): array
{
return [
'label' => __('Category'),
'column' => 'category_id',
'type' => 'Picklist',
'options' => Category::query()->orderBy('name')->pluck('name', 'id')->toArray(),
];
}Creating a Filter Trait
Extract a filter into a trait so several lists can share it — file location app-modules/{module}/src/Traits/{Name}FilterTrait.php, containing nothing but the get{Name}ListFilter() method above. The noerd package ships ShowFromFilterTrait (date ranges), TenantFilterTrait and SetupLanguageFilterTrait — see Reusable Traits.
Option values do not have to be stored values — a year filter may offer "{$year}-01-01" => $year and transform the selection into a date range in the query (see Filter Preselection).
Using the Filter in a Component
Use the trait — NoerdList::tableFilters() auto-discovers every get*ListFilter method — and apply the filters in listData():
new class extends Component {
use NoerdList;
use CategoryFilterTrait;
public $listModel = Item::class;
public ?string $detailRoute = 'inventory.item.detail';
public $detailComponent = 'inventory::item-detail';
public function listData(): array
{
$query = $this->listQuery($this->listModel);
$this->applyListFilters($query);
return $this->buildList($query->paginate($this->perPage));
}
};Override tableFilters() only to show filters conditionally:
#[Computed]
public function tableFilters(): array
{
if (Category::query()->count() < 2) {
return [];
}
return [$this->getCategoryListFilter()];
}Filter Preselection
A dropdown value does not have to be applied verbatim: derive the actual constraint in listData() and apply it to the query yourself instead of calling applyListFilters(). This is useful when the value needs to be transformed — e.g. a year selection into a date range, with the current year as the default while nothing is selected:
protected function getYearListFilter(): array
{
$options = [];
foreach (range(now()->year, now()->year - 5) as $year) {
$options["{$year}-01-01"] = (string) $year;
}
return [
'label' => __('Year'),
'column' => 'created_at',
'type' => 'Picklist',
'options' => $options,
];
}
private function dateRange(): array
{
$date = isset($this->listFilters['created_at'])
? Carbon::parse($this->listFilters['created_at'])
: Carbon::today();
return [
$date->copy()->startOfYear()->toDateString(),
$date->copy()->endOfYear()->toDateString(),
];
}
public function listData(): array
{
[$from, $until] = $this->dateRange();
$rows = $this->listQuery($this->listModel)
->whereBetween('created_at', [$from, $until])
->paginate($this->perPage);
return $this->buildList($rows);
}Session Persistence
storeActiveListFilters() is called by the filter UI on every change (wire:change on the dropdown). The trait implementation writes the selection to the session (session(['listFilters' => …])) and resets pagination to page 1, so the selection survives a reload. The bucket is deliberately shared across lists: a detail page may seed it via NoerdPage::setPreselect() so a related list opens pre-filtered — only whitelisted columns ever apply (see Security).
Override it to add side effects — always keep the session write:
public function storeActiveListFilters(): void
{
session(['listFilters' => $this->listFilters]);
$this->resetPage();
if (! empty($this->listFilters['language'])) {
session([SetupLanguage::SESSION_KEY => $this->listFilters['language']]);
}
}Security
The NoerdList trait extracts allowed columns from the tableFilters() output. Only columns returned by the filter methods can be filtered on, preventing users from manipulating filter parameters to query arbitrary columns.
To allow additional keys (e.g. a filter seeded via setPreselect() that no dropdown declares), override the whitelist on the component:
protected function getAllowedListFilterColumns(): array
{
return ['category_id', 'created_at'];
}