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

Ajax Multiselect Nova Field Laravel Package

bessamu/ajax-multiselect-nova-field

A Laravel Nova field that provides an AJAX-powered multi-select input with searchable, dynamically loaded options. Easily attach multiple related models through a clean, responsive interface, with flexible configuration and simple integration into Nova resources.

View on GitHub
Deep Wiki
Context7

Getting Started

Requirements check first. Open composer.json in the repo before anything else. The last release is July 2024, which almost certainly targets Nova 4 (Vue 2 / laravel-nova mix) and may or may not declare Nova 5 support. If you are on Nova 5 you are in unsupported territory — budget time to rebuild the field's JS (resources/js) against Vue 3 if the shipped dist/js/field.js won't load.

Install. Fields are auto-discovered via the package's service provider, so typically:

composer require bessamu/ajax-multiselect-nova-field

Then check the README/src/ for a vendor:publish step — some Nova fields ship a config file (default endpoint, result limit) or need public/vendor/... assets published. If there is no publish tag, the compiled JS is served straight out of the package and there is nothing to build.

Look at these three things in this order:

  1. README.md — the canonical usage snippet and any config keys.
  2. src/ — the field class (namespace is roughly Bessamu\AjaxMultiselectNovaField\...; confirm the exact class name and public setters here, since docs on a 5-star package are usually thin).
  3. resources/js/components/Field.vue (or similar) — this tells you exactly what request it fires, what parameters it sends, how it debounces, and how it receives the option payload. That component is the real API contract.

First use case: a BelongsToMany with too many rows for the stock field. The built-in Nova BelongsToMany loads every option for its select, which falls over past a few thousand records. Swap it for this field on that one relationship:

use Bessamu\AjaxMultiselectNovaField\AjaxMultiselect; // verify class name in src/

public function fields(Request $request): array
{
    return [
        // ...other fields
        AjaxMultiselect::make('Authors')
            ->relation('authors')      // method name on the model: $post->authors()
            ->searchableColumns(['name', 'email']),
    ];
}
// app/Models/Post.php
public function authors(): BelongsToMany
{
    return $this->belongsToMany(User::class);
}

Keep the relationship name identical to the make() attribute or whatever setter the field exposes — a mismatch is the most common reason the field renders empty and silently saves nothing.

Implementation Patterns

Scope the search, always. The endpoint is a live query on your models; without a constraint it will happily return every user in the table. Whatever the field's query callback is called (->query(fn ($q) => ...), ->optionsQuery(), etc. — confirm in src/), use it to hard-limit results:

AjaxMultiselect::make('Authors')
    ->relation('authors')
    ->query(fn ($query) => $query
        ->where('tenant_id', request()->user()->tenant_id)
        ->orderBy('name')
        ->limit(20)
    ),

The limit() matters as much as the where(): it caps the payload when a user types a single vowel.

Hydrate existing selections explicitly. On an edit screen, the component only has IDs for already-attached records and needs labels to render chips. Most packages solve this with a resolveUsing/displayUsing callback that eager-loads the relation. If the field renders raw IDs on edit, that's the missing piece:

->resolveUsing(fn ($value) => $value?->pluck('name', 'id'))

Verify against the component: it may expect [{id, label}] or a plain keyed map.

Validation belongs on the field, not the component. The Vue side is a search widget; treat it as an untrusted input like any other:

AjaxMultiselect::make('Authors')
    ->relation('authors')
    ->rules('array')
    ->rules(['authors.*' => 'integer|exists:users,id']),

exists: on the child rule is what stops someone POSTing a forged ID past the search endpoint.

Pivot attributes and sync behaviour. Because this is effectively a BelongsToMany, be explicit about whether saving syncs (detaching removed rows) or attaches only. Read the field's fill/resolve logic in src/ before shipping to production — this is exactly where custom relationship fields diverge from the stock behaviour, and where data loss happens.

Cascading / dependent selects. Combine with Nova's native reactivity rather than inventing your own: a country field that calls ->dependsOn(['country']) and this field re-querying with the parent's value as a filter. Because the child's request is async, expect a moment where the child still shows options from the previous parent — you need an explicit reset, usually by keying the component on the parent value.

Reuse via a small helper. If you attach this on five resources, don't repeat the query closure. Put a scopeSearchable(Builder $q, string $term, array $columns) scope on the model and have every field's callback call it:

->query(fn ($q) => $q->searchable($term ?? '', ['name', 'email']))

That way indexing strategy and tenant filtering live in one place.

Test the endpoint, not the widget. A Dusk test for a search box is brittle. Instead, assert the HTTP route returns the expected JSON with and without auth, and use a plain field test to assert the field serialises with searchableColumns etc. php artisan route:list | grep -i multiselect tells you the route name to hit.

Gotchas and Tips

Out-of-order responses. Async typeahead components routinely suffer from "fast typer sees stale results": request for an resolves after the request for anna. Confirm the Vue component aborts the previous Nova.request() (AbortController / cancelToken). If it doesn't, this is the one patch worth making in a fork — it otherwise produces genuinely wrong-looking selections.

Missing labels on edit is a hydration bug, not a search bug. If chips show #412 instead of a name, the search worked fine; the field never loaded the current relation. Fix via resolveUsing/with() on the resource, or add the relation to the resource's with array.

LIKE '%term%' does not scale. The whole point of this field is large datasets, but a leading-wildcard LIKE on an unindexed name column is a full scan on every keystroke. Either add a lower(name) index and use prefix matching, or move to a tsvector/FULLTEXT index and switch the callback to whereFullText. The field will not do this for you.

Guard the endpoint's authorization. A remote search route bypasses whatever restrictions the stock Nova field applied. If the component hits a Nova API route, it is inside Nova's middleware (nova-api), which is good. If it hits an app route, make sure it's in the auth/tenant middleware group and that the query is scoped to what the current user may see — a global scope is doing more work here than usual.

N+1 on index views. If you use the field for display on an index resource, Nova resolves it per row. ->displayUsing() plus a with('authors') on the resource is mandatory, otherwise a 50-row index fires 50 queries.

Nova 5 / Vue 3. The shipped dist/js bundle was built pre-Nova 5. Symptoms are a blank field, a console error about Vue being undefined, or mix chunk load failures. The fix is in webpack.mix.js/vite.config.js plus the laravel-nova dev dependency — not something you can patch from the host app.

Asset caching. After composer update, run php artisan nova:publish (or at minimum clear your browser cache). A stale field.js from a previous version is indistinguishable from a broken update, and this bites hard on a 5-star package where the last release is a year old.

For small relationships, don't bother. Under a few hundred records, the stock BelongsToMany with ->searchable() is supported, tested, and free. Reaching for this package means you've measured the problem.

Extension points worth knowing. The two hooks that carry the weight are the query/options callback (all scoping and ordering) and the resolve/display callback (all hydration and formatting). Everything else — debounce timing, minimum characters, page size — lives in the Vue component and is best changed with a fork + repositories entry in composer.json (type: vcs) rather than in-place vendor edits.

Debugging checklist when the field is silent: network tab for the request URL and response shape; compare the JSON keys to what Field.vue reads (data, resources, options); check that the relation name matches; check that the field's component resolves (404 on field.js means the package isn't registered); check storage/logs for a 500 from the endpoint. Nine times out of ten it's a shape mismatch between your callback's return value and what the component expects — which is why reading the component first saves an hour.

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.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk