outl1ne/nova-multiselect-field
Laravel Nova multiselect field with searchable multi- and single-select, async resource querying, drag-and-drop reordering, option dependencies, distinct value groups, and on-the-fly tagging. Stores values as JSON and supports light and dark modes.
Install and confirm your Nova version. The current release targets Laravel Nova 5 and PHP 8.1+. If you're still on Nova 4 or 3, use the version branches (nova-v4, nova-v3) rather than the default — pinning the Composer constraint correctly up front saves a broken admin panel later.
composer require outl1ne/nova-multiselect-field
Decide your storage column first. This is the single biggest day-one decision. By default values are persisted as a stringified JSON array ('["liverpool","tottenham"]'), so the DB column should be string, text, or varchar. If you're using a native JSON column, chain ->saveAsJSON(). Getting this wrong is the most common cause of "the field saves but won't load" tickets.
Where to look first: the README's options table. Almost every capability in the package (max, reorderable, taggable, distinct, singleSelect, nullable, index display controls) is a single fluent method documented there. Skim that table before writing custom JS or reaching for a different field type.
First use case — a simple static-select field:
use Outl1ne\MultiselectField\Multiselect;
public function fields(Request $request)
{
return [
Multiselect::make('Football teams', 'football_teams')
->options([
'liverpool' => 'Liverpool FC',
'tottenham' => 'Tottenham Hotspur',
])
->placeholder('Choose football teams')
->max(4),
];
}
The API mirrors Nova's native Select field, so if you've built a Select before, you already know this one. The difference is that the returned value is an array of keys, and it can support search, async loading, and reordering out of the box.
Static options with groups. Use Laravel's familiar option-group syntax; the package reads the group key and renders grouped dropdowns. Add ->groupSelect() when users should be able to pick an entire group in one click — handy for permission pickers where "all admin perms" is a common action.
->options([
'MS' => ['label' => 'Small', 'group' => 'Men Sizes'],
'WS' => ['label' => 'Small', 'group' => 'Women Sizes'],
])
->groupSelect()
Async model querying. For large datasets, don't load everything into the page. asyncResource(Artist::class) wires up a Resource-backed search endpoint for you; reach for ->api('/api/...', Artist::class) only when you need custom filtering logic outside the Resource's search. Both accept the user's input via the search query parameter and expect a plain key-value map back.
BelongsToMany relationships. This is the pattern that replaces a third-party pivot UI in most projects:
public function categories()
{
return $this->belongsToMany(Category::class);
}
Multiselect::make('Categories', 'categories')
->belongsToMany(Category::class), // searchable, loads on type
// ->belongsToMany(Category::class, false) // preloads everything on page load
Default to the searchable form unless the related table is guaranteed small; the README explicitly warns against preloading for unbounded row counts.
Dependent dropdowns. optionsDependOn('country', [...]) gives you cascading selects without any frontend code. Combine with ->optionsDependOnMax([...]) when the cap varies by parent — e.g. Italy allows one language, Singapore allows three. Note that the parent field's key is what drives the mapping, so keep your field attribute names stable.
Distinct groups. When you have several multiselects that must not share values (e.g. primary/secondary/tertiary category pickers), assign them the same ->distinct('categories') group. The frontend syncs selected values across the fields and disables already-used options — no custom JS needed.
Reorderable + taggable. ->reorderable() preserves the array order as the user drags, which lets you store user-defined sort orders directly. ->taggable() permits free-text values outside the options list, useful for flexible taxonomies where you want a controlled vocabulary but need an escape hatch. Pair ->clearOnSelect() with taggable to keep the input clean.
Display tuning for the index table. indexDelimiter, indexValueDisplayLimit, and indexCharDisplayLimit control how arrays render in the resource list. Tune these early — a multiselect with 50 tags will otherwise blow out the index row.
Reading values in the model. Since by default you get a JSON string, cast it on the model so the rest of your app deals in arrays:
protected $casts = [
'football_teams' => 'array', // for JSON columns (with saveAsJSON)
];
// or an accessor for the stringified default:
public function getFootballTeamsAttribute($v) { return json_decode($v, true) ?? []; }
Cosmetic overrides. If you need to change how a value looks on detail or how a tag looks in the form, register nova-multiselect-detail-field-value or form-multiselect-field-tag as a Vue component in app.js. The detail component receives field and values (array of labels); the tag component receives option and remove. This is the intended extension point — avoid forking the package.
json but you forget ->saveAsJSON(), you'll get double-encoded data. If the column is varchar but you add ->saveAsJSON(), you'll get invalid storage on strict databases. Match the column and the flag every time.->nullable() an empty selection stores null; without it, empty selections store []. Pick the behavior intentionally and mirror it in validation rules and accessors.singleSelect() changes the return type. Unlike the multiselect mode, it returns a scalar rather than an array — remember to adjust accessors, in: validation rules, and any Blade templates that expect foreach.belongsToMany($resource, false) for small sets or make sure your Resource's searchable columns are configured — asyncResource relies on them.id key, use the resourceKeyName() helper (added in 4.2) so returned option keys match what your model expects on save.distinct is per-request UI state. It disables options already used by sibling fields on the same form — don't rely on it as a server-side uniqueness guarantee. Enforce that in validation.optionsDependOn and readOnly. There have been fixes around readOnly not reacting to dependency changes; if you're on an older 4.x, upgrade to pick up the corrected reactivity.optionsLimit thoughtfully (default 1000). Loading 10 000 options into the DOM will freeze the form even before search kicks in.OptimistDigital\MultiselectField\Multiselect was renamed to Outl1ne\MultiselectField\Multiselect in 4.0 — update use statements and composer.json together.php artisan vendor:publish --provider="Outl1ne\MultiselectField\FieldServiceProvider" --tag="translations" rather than editing vendor files.dd($model->getRawOriginal('your_field')) — it shows exactly what's in the DB, which immediately clarifies whether the problem is the save format (saveAsJSON) or the read format (missing cast).How can I help you explore Laravel packages today?