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

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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']);
});

Gotchas and Tips

  • Filter state lives in the URL (Nova base64-encodes the filter payload), which is good for shareable links and bad for high-cardinality "select everything" — URLs blow past browser limits. Cap the option list; if users need dozens of values, reconsider the filter dimension entirely.
  • Persisted selections confuse users. Nova remembers filters per resource. A user returning to an index that shows an empty table will blame the data. Document the filter, and make sure your 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."
  • Option key/value orientation is the classic first-day mistake. Whether options() is label => value or value => label changes what lands in apply(). Confirm with the log trick once, then standardize across your codebase.
  • Performance is the query, not the package. Add an index on every filtered column, keep 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.
  • Nova upgrade coupling is the real lifecycle risk. Treat the package version like a pinned dependency and add "multi-select filters render and filter correctly" to your Nova upgrade checklist. The code surface is small and MIT-licensed, so a fork is a realistic fallback — keep one trivial hand-rolled filter class around as a known-good reference.
  • Frontend customization is a different skill. If a stakeholder wants the control restyled or behavior extended, that's Vue and Nova-component territory. Decide up front who owns that, or you'll be blocked on a community package.
  • Extension points, in order of preference: override 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.
  • Keep the blast radius small. This is an admin-plane UI add-on. Adopt it resource by resource, validate against production-like data (nulls, empty selection, high cardinality), and remember removal is just a Composer removal plus deleting the filters() entry.
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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package