Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Multiselect Filter Laravel Package

optimistdigital/nova-multiselect-filter

A Laravel Nova package that adds a multiselect filter to resource indexes, letting users filter records by multiple options in a single filter. Configurable options and simple integration for Nova admin panels.

View on GitHub
Deep Wiki
Context7

Getting Started

Resolve the vendor name before writing a line of code. The package ships under two Composer names depending on era: the original optimistdigital/nova-multiselect-filter and the current outl1ne/nova-multiselect-filter (the GitHub org is outl1ne, and most search results still point at the old name). Install the one that actually resolves against your Nova major, then confirm what you got:

composer require outl1ne/nova-multiselect-filter
composer show outl1ne/nova-multiselect-filter   # check version + nova constraint

There is no service provider, no publishable config, and no asset build — the Vue component ships precompiled. If you find yourself running npm install inside vendor/, you've taken a wrong turn.

Read two files, then stop. vendor/outl1ne/nova-multiselect-filter/src/MultiselectFilter.php is the whole contract — roughly options(), apply(), column(), key(). Pair that with the README usage block. Everything else is Nova's standard filter machinery you already know from SelectFilter.

Generate the stub, then swap the parent. php artisan nova:filter StatusFilter gives you a Laravel\Nova\Filters\Filter; change extends and implement options(). The smallest useful filter:

<?php

namespace App\Nova\Filters;

use Illuminate\Http\Request;
use Outl1ne\NovaMultiselectFilter\MultiselectFilter;

class StatusFilter extends MultiselectFilter
{
    public $name = 'Status';

    public function options(Request $request)
    {
        return [
            'draft'     => 'Draft',
            'published' => 'Published',
            'archived'  => 'Archived',
        ];
    }

    public function apply(Request $request, $query, $values)
    {
        $values = array_filter((array) $values);

        if (empty($values)) {
            return $query; // never emit whereIn([])
        }

        return $query->whereIn('status', $values);
    }
}

Register it with one line in the resource:

public function filters(Request $request)
{
    return [new StatusFilter];
}

First use case to ship: a triage queue — support tickets by status, orders by payment state, users by role or tier. Pick one low-traffic admin index, confirm in the browser that the dropdown actually mounts and fires, then roll the pattern out where it repeats.

When it misbehaves, the Network tab beats the PHP. A filter that "does nothing" is nearly always a Vue component that failed to mount, not a malformed query. Confirm the filter request fires at all before you open apply().

Implementation Patterns

Drive options from PHP enums, not string literals. The option key is what lands in your query — keep it as a single source of truth so a renamed case fails loudly at the type level:

public function options(Request $request)
{
    return collect(OrderStatus::cases())
        ->mapWithKeys(fn ($s) => [$s->value => $s->label()])
        ->all();
}

Relationship filters want whereHas, not whereIn. For tags, categories, or tenants the option keys are IDs and the constraint belongs on the relation:

public function options(Request $request)
{
    return Tag::orderBy('name')->pluck('name', 'id')->all();
}

public function apply(Request $request, $query, $values)
{
    $ids = array_filter((array) $values);

    return empty($ids)
        ? $query
        : $query->whereHas('tags', fn ($q) => $q->whereIn('tags.id', $ids));
}

Cache relationship-backed options. options() runs on every index render and again when the filter applies. Wrap the pluck() in Cache::remember('nova.tags.options', 300, ...) and bust it from the model's saved/deleted events. On a table of any size this is the highest-leverage line in the class.

Guard the empty case in every single filter. Depending on Nova version and whether the filter counts as "active", the filter can be sent through with nothing selected. Returning $query untouched is the invariant users expect: no selection means no constraint, never zero rows.

Extract a base class at the third filter. An abstract EnumMultiselectFilter with abstract public function enum(): string plus a shared apply() kills the copy-paste and gives you exactly one place to fix the empty-selection guard and the string-casting issue.

Know the boolean semantics and say them out loud. Within one multiselect, values are OR'd (whereIn). Across separate filters, Nova ANDs them. That matches intuition for "Status is Draft or Archived" + "Owner is me" — but if your domain makes it ambiguous, put it in the filter name or help text.

Localize labels, never keys. __('status.draft') => __('status.draft') is fine; keying options on a translated string means a copy edit silently breaks the filter.

Gate sensitive option sets with canSee():

public function canSee(Request $request)
{
    return $request->user()->can('viewAny', Customer::class);
}

Test apply() directly — no browser needed. Instantiate the filter, hand it a NovaRequest and a query, assert on bindings:

$query = Ticket::query();
(new StatusFilter)->apply(NovaRequest::create('/'), $query, ['draft', 'archived']);
$this->assertSame(['draft', 'archived'], $query->getBindings());

Add cases for [] and null. Those two tests catch the failure modes that make admins believe data disappeared.

Treat the filtered URL as an API. Nova serializes filter state into the query string, so a filtered index is a shareable link — support and ops will paste these into Slack. That contract only holds if option keys and the filter's key() stay stable across releases.

Lenses and metrics reuse the same class. A Lens can return the same filter from its filters(). Remember that apply() also runs against count queries behind metrics — keep it cheap and index-friendly.

Gotchas and Tips

Namespace drift is the #1 install-time bug. optimistdigital\... still dominates search results. If composer require succeeds but your use statement won't resolve, you installed one vendor and imported the other. Verify both the Composer name and the PHP namespace straight out of vendor/.

A green composer require proves nothing about rendering. Nova's asset pipeline has broken third-party filter components across majors, and the failure is silent — the filter renders (or doesn't) and simply never fires. Make "component mounts on one screen" an explicit item on every Nova upgrade checklist.

Never let whereIn([]) reach the query. MySQL returns zero rows, the admin sees an empty table, and the reasonable conclusion is that the app lost their data. Guard every apply().

Verify the shape of $values once, in writing. Depending on the underlying component version, the payload may arrive as a flat list of keys or as a keyed key => label map. Drop a Log::debug($values) into apply() on first run, record the shape as a comment, and code defensively (array_keys() vs array_values()) if it's map-shaped.

Integer keys come back as strings. pluck('name', 'id') hands you ["3", "7"]. Harmless under MySQL implicit casting, but it will bite on Postgres or a uuid PK — cast explicitly when the column type is strict.

Nulls never match. whereIn('status', [...]) excludes rows where status IS NULL. If "unset" is a real ops bucket, add an explicit __null sentinel option and translate it inside apply().

Filter keys are a public contract. Renaming the filter class or changing key() silently breaks every bookmarked or Slack-pasted filtered view. If you must change it, keep the old key working for a release.

Avoid thousands of bindings. A multiselect over a 50k-row table where someone hits "select all" produces a pathological query and can blow past max_allowed_packet. Constrain the option list — top N by usage, or a scoped subset — rather than dumping the table into a dropdown.

Options are not searchable server-side. The multiselect's search filters what's already loaded in the client. There's no async or paginated option loader here; if your option set genuinely can't fit in a dropdown, this package is the wrong tool and you should say so early.

Keep business logic out of the filter. The filter produces a query constraint; the definition of "which statuses count as open" belongs in a model scope or enum method. The moment a filter becomes load-bearing for reporting, it stops being removable in one line — and removability is the entire reason to accept a low-adoption dependency.

Pin it and put it on the upgrade runbook. Use an explicit version constraint rather than ^x, commit the lockfile, and reach for composer why-not outl1ne/nova-multiselect-filter <nova-version> when a Nova bump stalls. Name an owner who re-verifies the component mounts on each Nova major; MIT means forking is legal and cheap if upstream goes quiet.

Check the lockfile for a second package. Some versions lean on the sibling nova-multiselect-field component. If so, you've onboarded two Nova-major compatibility windows, not one — budget for both.

Write down the removal path while you're installing it: composer remove, delete the filter class, delete one line from filters(). If any of those three steps is hard, something has leaked out of the admin layer into your application — fix that before it calcifies.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels