klepak/nova-multiselect-filter
A Laravel Nova filter package that adds a multiselect filter, letting users filter resource listings by multiple values in one control. Useful for narrowing index views by several categories, statuses, or related options without adding separate filters.
Install
composer require klepak/nova-multiselect-filter
There is no asset publishing step in the typical Nova filter workflow — the filter renders its own Vue component via Nova's auto-registration. Verify by inspecting vendor/klepak/nova-multiselect-filter/src/ and resources/js/ after install; the base class name (likely Klepak\NovaMultiselectFilter\MultiselectFilter) is what you extend.
Check Nova compatibility first. Last release is December 2020, so this was built and tested against Nova 2/3. On Nova 4+ the Vue component may fail to mount, and the Filter contract changed (notably apply(Request $request, $query, $value) semantics and the default()/key() plumbing). Do a smoke test on your Nova version before wiring it into a resource.
First use case — a Resource with a status column you want to filter by one or many values, without building a custom Vue filter yourself:
// app/Nova/Filters/StatusFilter.php
use Klepak\NovaMultiselectFilter\MultiselectFilter;
class StatusFilter extends MultiselectFilter
{
public function name() { return 'Status'; }
public function options() {
return [
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
];
}
public function apply(Request $request, $query, $value)
{
$value = array_filter((array) $value);
return $value ? $query->whereIn('status', $value) : $query;
}
}
Register it in NovaServiceProvider::filters() (global) or in the Resource's filters(Request $request) (per-resource). Start with the per-resource form so you can iterate without affecting other resources.
Global vs. resource filter. Put cross-cutting filters (tenant, region, publish-state) in NovaServiceProvider::filters(). Put column-specific ones (e.g., user_id) in the Resource's filters() method — this keeps the dropdown scoped to where it makes sense and avoids "why is this filter showing on Orders?" confusion.
One filter class per column, not per value. The temptation is to add one filter per status. Resist it — a single multiselect filter with an options() map is one component and one query clause, whereas N single-select filters produce N round-trips and N where bindings.
Handle the "no selection" case explicitly. On Nova filter reset, $value may be null, [], or [''] depending on the Nova version. Normalize with array_filter((array) $value) before deciding whether to apply a whereIn. Returning the query untouched is the correct behavior — never whereIn('status', []) (which yields zero rows on MySQL).
Compose with relationship filters. When filtering on a related model, join eagerly and reuse the same whereIn idiom:
public function apply(Request $request, $query, $value)
{
$value = array_filter((array) $value);
return $value
? $query->whereHas('author', fn ($q) => $q->whereIn('team_id', $value))
: $query;
}
Combine with default() for dashboard UX. If you want the filter pre-selected (e.g., default to "active + published"), override default() in your subclass to return the array of keys. This is far cleaner than post-processing the request in the Resource.
Reach for the package's select shape, not a Select field. A common misstep is to assume you can drop a Laravel\Nova\Fields\Select into filters() — you can't, it needs a filter's key() / apply() contract. The multiselect package gives you that contract while keeping the multi-value UI.
Testing tip. Unit-test apply() directly: instantiate the filter, call apply($request, Model::query(), ['a','b']), assert toSql() or bindings. This is faster than exercising the Vue component through Dusk and catches the empty-array bug before QA does.
resources/js/components/Filter.vue, publish it (nova:publish or manual copy to nova-components/), and rebuild with the current Nova mix config. The PHP side is small; the Vue side is where breakage lives.src/ directory for namespace before guessing. A quick grep -R "class .*Filter" vendor/klepak/nova-multiselect-filter/src saves a stack trace.options() must return an associative array (key => label), not a list. If you pass ['Draft', 'Published'], the stored value will be the numeric index and your whereIn will silently match column values 0, 1, etc. Always use key => label.Nova::filters() order — filters are applied in sequence and a later filter that returns an unscoped $query can clobber an earlier one. Log $query->toSql() and getBindings() in apply() (guarded by app()->isLocal()) when results look wrong.?status[0]=draft&status[1]=published (or similar). If you write a custom bookmark/deep-link feature, don't hand-roll the encoding — reuse Nova's filter serializer, or you'll get single-item arrays where the component expects keys.apply() is a public method, you can subclass a shared base filter to centralize tenant scoping and only override options() per resource. This is the cleanest way to reuse the package across 20 resources without copying apply() 20 times.How can I help you explore Laravel packages today?