Filters
Filters allow users to narrow down resource index results by applying criteria. They appear as a collapsible panel on the resource index page with active filter pills for at-a-glance visibility.
Martis ships four built-in filter types and supports custom filters for advanced use cases.
Defining Filters
Register filters on a resource by overriding the filters() method:
use Illuminate\Http\Request;use Martis\Enums\ComparisonOperator; public function filters(Request $request): array{ return [ StatusFilter::make('Status'), RoleFilter::make('Role'), DateFilter::make('Created After') ->column('created_at') ->operator(ComparisonOperator::GreaterThanOrEqual), ];}Filters are applied to the index query in the order they are defined. When multiple filters are active, they combine with AND logic.
Available Filter Types
SelectFilter
A dropdown filter that lets users pick a single value from a list.
use Illuminate\Database\Eloquent\Builder;use Illuminate\Http\Request;use Martis\Filters\SelectFilter; class StatusFilter extends SelectFilter{ public function options(Request $request): array { return [ 'Active' => 'active', 'Inactive' => 'inactive', 'Pending' => 'pending', ]; } public function apply(Request $request, Builder $query, mixed $value): Builder { return $query->where('status', $value); }}Options format: keys are display labels, values are passed to apply(). This is Nova's filter order, the reverse of a field's options(), which reads [value => label] as Nova's fields do (see Fields → Option order).
Grouped options: organize large option lists into visual groups:
public function options(Request $request): array{ return [ 'Numbers' => [ 'Odd' => 'odd', 'Even' => 'even', ], 'Letters' => [ 'Vowels' => 'vowels', 'Consonants' => 'consonants', ], ];}Searchable: for large option lists, enable search within the dropdown:
SelectFilter::make('Country')->searchable()MultiSelectFilter
Since v1.31.0.
A searchable dropdown that lets users pick multiple values — an "any of these" facet (e.g. a growing set of tags). The selected value is an array of the chosen values, so apply() is typically a whereIn() (ANY-match). Mirrors SelectFilter (override options(), enable search with ->searchable()); the React side renders a PrimeReact MultiSelect.
Martis skips empty selections before apply() runs — an empty array carries no constraint, so it shows all records — which means you never have to guard against whereIn('col', []) compiling to WHERE 0 = 1. Add your own empty($value) guard only if you call apply() directly outside the request pipeline.
use Illuminate\Database\Eloquent\Builder;use Illuminate\Http\Request;use Martis\Filters\MultiSelectFilter; class TagsFilter extends MultiSelectFilter{ public function options(Request $request): array { return ['PHP' => 'php', 'Laravel' => 'laravel', 'Vue' => 'vue']; } public function apply(Request $request, Builder $query, mixed $value): Builder { return $query->whereHas('tags', fn ($q) => $q->whereIn('slug', (array) $value)); }}// in the resourcepublic function filters(Request $request): array{ return [TagsFilter::make('Tags')->searchable()];}BooleanFilter
A multi-toggle filter that lets users enable or disable multiple conditions.
use Illuminate\Database\Eloquent\Builder;use Illuminate\Http\Request;use Martis\Filters\BooleanFilter; class UserFlagsFilter extends BooleanFilter{ public function options(Request $request): array { return [ 'Admin' => 'is_admin', 'Verified' => 'is_verified', ]; } public function apply(Request $request, Builder $query, mixed $value): Builder { $value = is_array($value) ? $value : []; return $query ->when($value['is_admin'] ?? false, fn (Builder $q) => $q->where('is_admin', true)) ->when($value['is_verified'] ?? false, fn (Builder $q) => $q->where('is_verified', true)); }}The $value passed to apply() is an associative array where keys are option values and values are booleans indicating checked state.
The keys come from the request, and the package keeps them to your options (v2.4.0+). The filters query parameter is client input, so before apply() runs, the package drops every key that is not one of the option values options() declares (flat or grouped), turns each remaining value into a boolean (true, 1, '1', 'true', 'on' and 'yes' are checked) and, when no declared key is left, skips the filter as an empty selection. This holds on the index, in a lens and for the filters of a dashboard (ResourceController, LensRequest::withFilters(), MetricController), for every filter whose filterType() is boolean, a hand-written one included.
Read the keys you declared explicitly, as above, instead of looping over $value and using each key as a column name. A loop like foreach ($value as $column => $enabled) $query->where($column, true), which this page used to show, turns a key into a column name: with a request that names is_admin or settings->flag it filters on a column the filter never offered, and it answers a hidden column's value or a SQL error. The package's guard makes such a loop safe for the declared options only; a filter that has to loop does so over the list options() gives, as the martis:filter --boolean scaffold does:
// As strings: PHP turns the array key "1" into the integer 1, which a strict// comparison with the option value '1' would not match.$allowed = array_map('strval', array_values($this->options($request))); foreach ($value as $column => $enabled) { if ($enabled && in_array((string) $column, $allowed, true)) { $query->where($column, true); }}DateFilter
A date picker filter for filtering by a single date.
use Martis\Filters\DateFilter; use Martis\Enums\ComparisonOperator; DateFilter::make('Created After') ->column('created_at') ->operator(ComparisonOperator::GreaterThanOrEqual)Available operators: Equals, GreaterThanOrEqual, LessThanOrEqual, GreaterThan, LessThan.
DateFilter is a concrete class that can be used directly without subclassing. The $value is a date string in Y-m-d format.
Available methods:
| Method | Description | Default |
|---|---|---|
column(string $column) | Database column to filter. | Derived from filter name |
operator(ComparisonOperator $operator) | Typed comparison operator. Cases: Equals, GreaterThanOrEqual, LessThanOrEqual, GreaterThan, LessThan. | ComparisonOperator::Equals |
DateRangeFilter
Martis extension — built-in date range filter.
A date range filter with from and to inputs.
use Martis\Filters\DateRangeFilter; DateRangeFilter::make('Created Between') ->column('created_at')DateRangeFilter is a concrete class. The $value is an array with optional from and to keys in Y-m-d format. Either bound can be omitted for an open-ended range.
Custom Filters
All filters extend Martis\Filters\Filter. To create a fully custom filter, extend the base class directly:
use Illuminate\Database\Eloquent\Builder;use Illuminate\Http\Request;use Martis\Enums\FilterType;use Martis\Filters\Filter; class RecentlyActiveFilter extends Filter{ public function filterType(): FilterType { return FilterType::Select; // or Boolean, Date, DateRange } public function options(Request $request): array { return [ 'Last 24 hours' => '1', 'Last 7 days' => '7', 'Last 30 days' => '30', ]; } public function apply(Request $request, Builder $query, mixed $value): Builder { return $query->where('last_active_at', '>=', now()->subDays((int) $value)); }}apply() runs as Eloquent runs a local scope (v2.0.1), on the index and through a lens's withFilters(): what it adds is one group, and so is what the query held before it (the resource's scopes() and indexQuery(), a lens's own query). A filter written where('status', 'open')->orWhere('status', 'urgent') therefore reads (tenant) and (status = 'open' or status = 'urgent'), and cannot list another tenant's urgent records. Before v2.0.1 its orWhere() was appended to the hooks' clauses and did.
Default Values
Override the default() method to pre-select a filter value when the page loads:
public function default(): mixed{ return 'active';}When a filter has a default value, the frontend applies it automatically on the initial page load. The default is included in the index query, ensuring consistent and predictable behavior from the first request.
Authorization
Martis extension — per-filter authorization.
Use canSee() to control filter visibility based on the current request. This is consistent with how canSee() works on Fields and Actions.
StatusFilter::make('Internal Rating') ->canSee(fn ($request) => $request->user()->isAdmin())When a filter is hidden via canSee():
- It is excluded from the schema response (frontend never sees it)
- Even if a client sends the filter in query params, it is ignored on the backend
A filter soft-locked for the user (lockedFor(), requirePlan(), see Soft-gates) stays in the schema with its lock, but it is not applied (v2.4.0): a ?filters= that names it is ignored on the resource index, on a lens and on the cards of a dashboard, as one a canSee() hides is. The dashboard card routes also ignore a filter the user may not see (canSee()); before v2.4.0 they applied it.
Excluding a Filter from Lenses
Martis extension — keep a filter on the default index but skip it on lenses.
By default, lenses inherit Resource::filters() so the lens toolbar shows the same filters as the parent index. When a lens has a different intent (e.g. an audit log that always renders "today first" regardless of operator filtering), opt the filter out per-instance:
public function filters(Request $request): array{ return [ StatusFilter::make('Status'), DateRangeFilter::make('Period')->excludeFromLens(), ];}excludeFromLens() accepts a single boolean (defaults to true). The opt-out is per-filter and per-resource — each lens that builds its own filters() from scratch is unaffected. Use isExcludedFromLens(): bool to query the flag.
Dynamic Filters
When using the same filter class for multiple columns, pass the column in the constructor and override uriKey() to ensure uniqueness:
class ColumnStatusFilter extends SelectFilter{ public function __construct( string $name, protected string $column, ?string $uriKey = null, ) { parent::__construct($name, $uriKey ?? Str::kebab($column)); } public function apply(Request $request, Builder $query, mixed $value): Builder { return $query->where($this->column, $value); } public function options(Request $request): array { return [ 'Active' => 'active', 'Inactive' => 'inactive', ]; }} // Usage:public function filters(Request $request): array{ return [ new ColumnStatusFilter('Account Status', 'account_status'), new ColumnStatusFilter('Billing Status', 'billing_status'), ];}Metadata
Pass arbitrary metadata to the frontend using withMeta():
SelectFilter::make('Status')->withMeta(['placeholder' => 'Choose a status...'])Custom Component
Override the default frontend control with componentKey(). Since v1.31.0 the filter panel resolves the key from the component registry (mirroring cards, metrics, and fields) and renders your component with a { filter, value, onChange } contract; register it from your consumer-extension bundle (resources/js/martis-extensions/). Before v1.31.0 the key was serialized but the SPA ignored it.
SelectFilter::make('Status')->componentKey('my-custom-filter')// resources/js/martis-extensions/filters/MyCustomFilter.tsxexport default function MyCustomFilter({ filter, value, onChange }: { filter: FilterDefinition; value: unknown; onChange: (value: unknown) => void}) { // render any control; call onChange(newValue) to update the active filter}Filter Grid Layout (span)
Martis extension — filter layout control via spans.
Control how much horizontal space each filter occupies using a 12-column grid:
public function filters(Request $request): array{ return [ StatusFilter::make('Status')->span(4), // 1/3 width DateRangeFilter::make('Period')->span(8), // 2/3 width RegionFilter::make('Region')->span(3), // 1/4 width SearchFilter::make('Keyword')->span(9), // 3/4 width ];}Default spans: select/boolean = 3 columns, date-range = 6 columns. Override with ->span().
Responsive (v1.38.0+). The span applies from the md breakpoint (768px); below it every filter takes the full row, one per row, the same rule as the form grid and the dashboard grid. The panel never writes grid-column inline: each filter carries its span as the custom property --martis-filter-span and .martis-filter-grid in martis.css owns the placement per media query. A theme that wants the spans on phones too can override the mobile rule:
@media (max-width: 767px) { .martis-filter-grid > * { grid-column: span var(--martis-filter-span); }}Before v1.38.0 the panel stayed 12 columns at every viewport, so a span(3) filter was a quarter of a phone screen.
Placeholder
Martis extension — placeholder distinct from the filter name.
By default, a filter's empty-state placeholder (shown in the select/date control before a value is chosen) is the same as its display label. Use ->placeholder() to make them differ:
SelectFilter::make('Project')->searchable()->placeholder('Select…')Here the field label above the control still reads "Project", but the dropdown itself shows "Select…" until a value is picked. Passing null (or omitting the call) restores the default behaviour of falling back to the filter name.
Filter Interaction
- Filters combine with search, sort, pagination, and trashed controls.
- Changing a filter resets the page to 1.
- Active filters are shown as pill tags below the filter button, visible even when the panel is closed.
- The panel never opens by itself (v2.18.3). On the resource index, a lens and a dashboard it opens when the user clicks Filters; a
?filters=link (aMenuItem::filter()item, a shared URL) applies the filters and shows their pills and count with the panel collapsed. On the index a saved view restores a panel the user left open. Once open, the panel stays open while the user changes the filters (on a lens it used to collapse after each change). - Each pill has a per-filter clear button (X) to remove individual filters.
- A global "Clear all filters" button removes all active filters at once.
- Filters are serialized as a JSON object in the
?filters=query parameter.
Artisan Command
Generate new filter classes with the martis:filter command:
# Select filter (default)php artisan martis:filter StatusFilter # Boolean filterphp artisan martis:filter UserFlagsFilter --boolean # Date filterphp artisan martis:filter CreatedAfterFilter --dateGenerated classes are placed in app/Martis/Filters/.
API
Query parameter
GET /api/resources/{resource}?filters={"status":"active","created-after":"2025-01-01"}The filters parameter is a JSON-encoded object where keys are filter uriKey values and values are the selected filter values.
Schema
Filter definitions are included in the schema endpoint response:
GET /api/resources/{resource}/schemaEach filter in the filters array includes:
{ "type": "filter", "filterType": "select", "name": "Status", "uriKey": "status", "component": null, "options": [ { "label": "Active", "value": "active" }, { "label": "Inactive", "value": "inactive" } ], "default": null, "placeholder": null, "meta": { "searchable": false }}Grouped options include a group key:
{ "options": [ { "label": "Odd", "value": "odd", "group": "Numbers" }, { "label": "Even", "value": "even", "group": "Numbers" }, { "label": "Vowels", "value": "vowels", "group": "Letters" } ]}Martis Differentials
Features unique to Martis.
| Differential | Description |
|---|---|
| DateRangeFilter | Built-in date range filter with from/to inputs. |
| canSee() authorization | Per-filter visibility control via canSee() callback. |
| Active filter pills | Pill tags showing active filters with name and value, visible even when the panel is closed. |
| Per-filter clear | Individual clear button (X) on each filter pill. |
| Default values on load | Defaults are applied correctly on the frontend initial load and included in the first query. |
| Searchable select | Built-in searchable() option on SelectFilter. |