rcknr/nova-multiselect-filter
A Laravel Nova filter that adds a multi-select dropdown, letting users filter resources by several values at once instead of one. Easy to configure for statuses, tags, categories, or relationships.
Install and confirm compatibility before anything else. The package is a thin Nova filter — a Filter subclass plus a Vue component — so the only real risk at install time is version drift, not code size.
composer require rcknr/nova-multiselect-filter
Then, before writing a single line of filter code, do these three checks:
vendor/rcknr/nova-multiselect-filter/composer.json. Look at the laravel/nova constraint. If it's a loose * or an old major, do not trust it — pin an explicit version in your root composer.json ("rcknr/nova-multiselect-filter": "1.0.0") so a silent upstream update can't change admin behaviour.vendor/rcknr/nova-multiselect-filter/dist/js/ (or resources/js/components/). Nova 4+ is Vue 3; if the bundled component is Options API written for Vue 2 but ships a compiled dist, it may still mount fine — but if it references Vue.component, this.$set, or a Vue 2 global, treat it as a hard stop on Nova 4/5.vendor/rcknr/nova-multiselect-filter/src/. Packagist vendor is rcknr, the repo lives under skauk, and small single-maintainer packages rename namespaces between tags. Read the source once, don't guess.Where to look first. The package is ~200 lines. Read the filter class's apply() and options() signatures, and the Vue component's data()/props — that tells you exactly what payload the component sends (an array, an object keyed by value, or a comma-joined string) and therefore what your apply() receives.
First use case — the one that justifies the dependency. Pick a single high-traffic list view where operators currently re-run the same filter repeatedly: a support queue filtered by status, an orders index filtered by fulfilment state, users by plan. Don't start with a relationship or a dynamic option source.
// app/Nova/Filters/StatusFilter.php
namespace App\Nova\Filters;
use Laravel\Nova\Http\Requests\NovaRequest;
class StatusFilter extends \Rcknr\NovaMultiselectFilter\MultiselectFilter
{
public $name = 'Status';
public function options(NovaRequest $request)
{
return [
'Draft' => 'draft',
'Published' => 'published',
'Archived' => 'archived',
];
}
}
Register it in the resource and you have a working pilot:
// app/Nova/Order.php
public function filters(NovaRequest $request)
{
return [
new StatusFilter,
];
}
Verify in the browser: select two values, confirm ?filter= in the URL, confirm the row count matches a hand-written whereIn count in tinker. That single comparison is your acceptance test for go/no-go.
Write apply() yourself every time. Never rely on a package-provided default. Multi-select filters fail silently as wrong data, not exceptions, so the query semantics need to be visible in your own codebase.
public function apply(NovaRequest $request, $query, $value)
{
$values = array_values(array_filter(
(array) $value,
fn ($v) => $v !== '' && $v !== null
));
if ($values === []) {
return $query; // empty selection = show everything
}
return $query->whereIn('status', $values);
}
The (array) cast matters — depending on the component's payload the filter may arrive as a scalar when exactly one option is selected, and whereIn('status', 'draft') throws or misbehaves.
Dynamic option sources — cache them. options() runs on every filter-panel render, and the panel renders on every index load.
public function options(NovaRequest $request)
{
return Cache::remember('nova.statuses', 300, fn () =>
Status::query()->orderBy('name')->pluck('name', 'slug')->all()
);
}
Keep the returned shape [label => value]. If you must use pluck('label', 'id'), be aware of the key-casting gotcha below.
Reusable base filter. The main day-to-day win is not the widget, it's that you stop hand-rolling multi-select per resource. Extract a base class once you have two or three of these:
abstract class MultiselectColumnFilter extends \Rcknr\NovaMultiselectFilter\MultiselectFilter
{
abstract protected function column(): string;
public function apply(NovaRequest $request, $query, $value)
{
$values = array_values(array_filter((array) $value, fn ($v) => $v !== '' && $v !== null));
return $values === [] ? $query : $query->whereIn($this->column(), $values);
}
}
Now each concrete filter is options + column name, five lines.
Relationship columns. For a BelongsTo (e.g. orders by customer region), whereIn on the foreign key won't work — use whereHas:
public function apply(NovaRequest $request, $query, $value)
{
$ids = array_filter((array) $value);
return $ids === []
? $query
: $query->whereHas('customer', fn ($q) => $q->whereIn('region_id', $ids));
}
Test the relationship case separately — it is the most common place this package's defaults don't stretch.
Multi-column filters ("state group"). A single dropdown that maps friendly labels onto combinations is often more useful than raw enums:
public function options(NovaRequest $request)
{
return [
'Awaiting payment' => 'unpaid',
'Ready to ship' => 'paid_unshipped',
'In transit' => 'shipped',
];
}
public function apply(NovaRequest $request, $query, $value)
{
$values = (array) $value;
return $query->where(function ($q) use ($values) {
foreach ($values as $state) {
$q->orWhere(fn ($w) => $this->applyState($w, $state));
}
});
}
Default selection. Filters support ->default(...). Use it sparingly — a default of "Open + Pending" on a support queue is genuinely helpful; a hidden default that silently narrows results is how operators lose trust in the admin panel. If you set a default, show it clearly in the UI (the widget does) and document it in the resource.
Testing. Don't assert on the Vue layer; assert on apply(). It's a pure query-builder function and trivially testable:
public function test_multi_select_filters_to_any_of_the_selected_values()
{
Order::factory()->create(['status' => 'draft']);
Order::factory()->create(['status' => 'archived']);
Order::factory()->create(['status' => 'published']);
$filter = new StatusFilter;
$results = $filter->apply(
NovaRequest::create('/', 'GET'),
Order::query(),
['draft', 'archived']
)->get();
$this->assertEqualsCanonicalizing(['draft', 'archived'], $results->pluck('status')->all());
}
public function test_empty_selection_returns_all_rows()
{
Order::factory()->count(3)->create();
$results = (new StatusFilter)->apply(
NovaRequest::create('/', 'GET'), Order::query(), []
)->get();
$this->assertCount(3, $results);
}
Cover four cases per filter: empty, single, multiple, all-selected. These are the four that break.
Rollout order. One resource → verify with real data → two or three resources → document the base-class pattern → standardize. Add the package to your Nova upgrade checklist in the same PR you install it.
Empty selection semantics are the number one bug. Decide and document whether [] means "all rows" or "no rows." Nova's native single-select filters typically disappear when unset; the multiselect widget can legitimately submit an empty array after a user deselects everything. Whichever you choose, make apply() explicit about it and test it. Silent wrong-row-counts are far more damaging than an exception.
PHP casts numeric string keys to integers. If your options are ['01' => '01', '02' => '02'] or you pluck('label', 'id'), PHP hands you [1 => '01', 2 => '02'] — the widget will display 1 and 2, not the padded labels. Fix by prefixing keys ('id_1') and stripping the prefix in apply(), or by reshaping to [['label' => ..., 'value' => ...]] if the component supports it.
Booleans are worse. [false => 'No', true => 'Yes'] becomes [0 => 'No', 1 => 'Yes']. Always use string keys or integers and map back explicitly.
Vue 2 vs Vue 3 is a hard stop. Nova 3 is Vue 2, Nova 4+ is Vue 3. A component that renders an empty dropdown (not an error — just empty) is almost always this. Check the browser console for Vue is not defined, $set is not a function, or a missing component registration before you spend time debugging your options() method.
Remembered filter state outlives the filter. Nova persists filter state per resource per user. When you remove or rename a filter, stale serialized state can linger and produce confusing results. Clear it by changing the filter's uriKey() (or the user's saved state) rather than by re-filtering and hoping. Do this deliberately during rollout, not by accident.
Composition with search and other filters. Test your filter alongside Nova's global search and at least one other active filter. Order of application matters if any of your filters widen the query (orWhere at the top level will leak past search constraints) — always scope orWhere groups inside a where(function ($q) { ... }) closure.
Relationship + nullable foreign keys. whereIn('region_id', [...]) excludes NULL rows silently. If "unassigned" is a state operators care about, model it as an explicit option and handle it with orWhereNull.
Large option sets. The widget loads everything server-side into a checkbox list. At a few hundred options it's already unusable. Cap around 50–100, or swap the filter for a searchable/async control. If you need async, this package is the wrong tool — build the filter yourself.
Index the filtered columns. Multi-select produces WHERE col IN (...); latency is your database's problem, not the package's. Check EXPLAIN on a production-like row count before you promise anything. An unindexed status column on a million-row table will make the admin index feel broken.
Pin the version and vendor the source. 26 stars, zero dependents, one maintainer, no test suite, no SLA. Pin an exact version in composer.json. If the filter lands on a business-critical resource, copy it into app/Nova/Filters/ or a private fork — the exit cost is a couple of hours now versus an unbounded wait if upstream goes quiet.
Debugging checklist when it "doesn't filter." In order: (1) is the filter actually registered in filters() on that resource; (2) what does the network request carry — inspect the filter query param in devtools; (3) does options() return the shape you think it does (dd() it once); (4) does apply() receive an array (Log::info(gettype($value))); (5) does the equivalent whereIn in tinker return the count you expect. Nine times out of ten it's step 2 or 3.
Upgrade cadence. Budget half a day per Nova major: bump Nova, re-mount the filter, verify empty/single/multi behaviour against a smoke test. Nova's filter API is semi-private; the failure mode is a filter that renders but no longer constrains the query. Put a post-upgrade admin-filter smoke test in the release checklist so it's caught by CI, not by an operator.
How can I help you explore Laravel packages today?