outl1ne/nova-multiselect-filter
A Laravel Nova filter package that adds a searchable multiselect dropdown, letting users filter resources by several values at once instead of only one. It's a simple drop-in replacement for Nova's default filter UI, easy to install, configure, and customize for any resource.
Install and confirm compatibility first.
composer require outl1ne/nova-multiselect-filter
Before you write a filter, spend two minutes on due diligence: check the installed Nova major against the package's composer.json constraints and latest tag. The package pins to Nova's internal filter contract, and Nova majors move Vue versions (Vue 2 on Nova 3, Vue 3 on Nova 4/5). The release metadata in this listing is future-dated, so trust the repository tags and changelog over any summary you were given.
Create your first filter. Generate a normal Nova filter, then swap the base class:
php artisan nova:filter StatusFilter
<?php
namespace App\Nova\Filters;
use Illuminate\Http\Request;
use Outl1ne\NovaMultiselectFilter\MultiselectFilter;
class StatusFilter extends MultiselectFilter
{
public function apply(Request $request, $query, $value)
{
$value = (array) $value;
if (empty($value)) {
return $query;
}
return $query->whereIn('status', $value);
}
public function options(Request $request)
{
return [
'Draft' => 'draft',
'Pending' => 'pending',
'Published' => 'published',
'Archived' => 'archived',
];
}
}
If the MultiselectFilter import fails, open vendor/outl1ne/nova-multiselect-filter/src/ and confirm the class name and namespace — that directory is the authoritative reference, and the base class is small enough to read in one sitting.
Register it on a resource:
public function filters(Request $request)
{
return [
new StatusFilter,
];
}
Your first use case should be a low-traffic resource with a bounded, indexed status/category column. Open the index, select two or three values, and confirm the result set before touching anything else.
One minute of debugging that saves an hour: apply() does not receive the shape you assume until you look. Temporarily Log::info(json_encode($value)) inside apply() (never dd() — it corrupts the JSON index response) and check the log to confirm whether you get ['draft','pending'] or a label-keyed array.
Guard empty selection, always. A whereIn against [] silently returns zero rows. Every filter you write should early-return $query when the selection is empty or contains only null/''. This is the single most common source of "the filter made my data disappear" bug reports.
Reusable base filter with a configurable column. Rather than copy-pasting a filter per column, parameterize:
class ColumnMultiselectFilter extends MultiselectFilter
{
public function __construct(
protected string $column,
protected array $options,
protected ?string $label = null,
) {}
public function name()
{
return $this->label ?? Str::headline($this->column);
}
public function options(Request $request)
{
return $this->options;
}
public function apply(Request $request, $query, $value)
{
$value = array_filter((array) $value, fn ($v) => $v !== null && $v !== '');
return empty($value) ? $query : $query->whereIn($this->column, $value);
}
}
Then new ColumnMultiselectFilter('status', OrderStatus::labels()) in filters(). One class, many resources, no divergence.
Relationship-backed filters. Filter on a foreign key rather than joining:
return $query->whereIn('team_id', $value);
Only reach for whereHas() when the option values are not the FK itself (e.g. filtering by tag name through a pivot). whereHas with a large IN is meaningfully slower than an FK whereIn — prefer exposing the FK as the option value.
JSON / array columns. Use whereJsonContains semantics carefully; a multi-select "any of" over a JSON array is not the same as a scalar whereIn. Validate with production-shaped data before shipping.
Type coherence. Enum-backed and integer columns need coercion. Cast values in apply() (array_map('intval', $value)) rather than relying on MySQL's implicit casting, so the filter behaves identically on Postgres.
Scoped and authorized options. options() runs as the authenticated user in an admin surface. If options are tenant- or role-scoped, apply the scope inside options() — do not filter client-side. An option list that leaks another tenant's labels is a compliance issue, not a cosmetic one.
Default selection. Where supported by your Nova version, ->default([...]) gives a sensible landing state (e.g. default to "Open" statuses). Test the reset behavior afterwards — users expect "Reset Filters" to actually clear it.
Lenses and shared resources. The same filter class works in Lens::filters(). If several resources share a filter, expose it through a base Resource class and merge it in filters() rather than duplicating registrations, so a Nova upgrade has one place to fix.
Test the query, not the UI. A PHPUnit/Pest test against apply() is cheap and catches the empty-selection and type bugs:
it('filters by multiple statuses', function () {
$query = Order::query();
(new StatusFilter)->apply(request(), $query, ['draft', 'pending']);
expect($query->toSql())->toContain('where in');
expect($query->getBindings())->toBe(['draft', 'pending']);
});
apply() guards are right so a stale empty selection can't silently hide everything.apply() must return the query. Forgetting return is a silent no-op that looks like the filter "doesn't work."options() is label => value or value => label changes what lands in apply(). Confirm with the log trick once, then standardize across your codebase.options() free of per-row queries (no N+1 in an option resolver — it runs during index serialization), and cache expensive option sets with Cache::remember where the data is stable.name(), override options(), compose via constructor arguments. Editing the vendored Vue component should be a last resort — vendor it or fork it deliberately if you must, never patch in place.filters() entry.How can I help you explore Laravel packages today?