Introduction
A Nova select field whose options are resolved on the server, scoped to the fields it depends on.
What it does
Declare which field this one depends on and where its options come from. When the parent changes, Nova re-resolves the field on its own sync endpoint and sends back a fresh option set — scoped, limited, and searched in the database rather than the browser.
Select::make('Country', 'country_id')->options($countries),
AjaxSelect::make('City', 'city_id')
->parent('country_id')
->optionsFromModel(City::class, query:
fn ($q, $c) => $q->where('country_id', $c->parent()))
->labelFrom('city.name'),
No route to write, no controller, no {value, display} mapping, and no second field to show the label on the detail page.
When not to use it
Nova already has dependent fields. dependsOn() re-renders a field when another changes, and inside that callback you can call options() with whatever the new parent implies. If your option sets are small and already in memory — a status list, twenty categories, the regions of one country — use it, and skip this package.
This one exists for options that are too many to send, or that do not live in your database at all.
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Nova 5
Installation
Two lines. No migrations, no tables, nothing you must publish.
Install the package
composer require gabrielesbaiz/nova-ajax-select
The service provider is auto-discovered and the compiled field ships with the package, so there are no assets to build or publish.
Optional publishing
php artisan vendor:publish --tag=nova-ajax-select-config
php artisan vendor:publish --tag=nova-ajax-select-lang
Both are optional. The defaults work untouched; publish the config to change the cache, search or validation defaults, and the language files to change the wording or add a locale.
Your first field
use Gabrielesbaiz\NovaAjaxSelect\AjaxSelect;
AjaxSelect::make('City', 'city_id')
->parent('country_id')
->options(fn ($c) => City::query()
->where('country_id', $c->parent())
->pluck('name', 'id')),
If you already have alexwenzel/ajax-select installed, remove it. Both packages once registered the same asset handle, and Nova serves scripts by name with ->first() — so one bundle was never served at all.
Using the field
Everything the field does, in the order you are likely to need it.
A dependent field
parent() declares which attributes to watch. When one changes, Nova re-resolves this field on the server and sends back a fresh option set.
AjaxSelect::make('City', 'city_id')
->parent('country_id')
->options(fn ($c) => City::query()
->where('country_id', $c->parent())
->pluck('name', 'id')),
There is no route to register, because the request is Nova's own field-sync request — authorized by Nova, for this resource, for this user, before your closure is ever called.
Chains
Each level emits its own change, so chains of any depth work.
Select::make('Country', 'country_id')->options($countries),
AjaxSelect::make('Region', 'region_id')
->parent('country_id')
->optionsFromModel(Region::class, query:
fn ($q, $c) => $q->where('country_id', $c->parent())),
AjaxSelect::make('City', 'city_id')
->parent('region_id')
->optionsFromModel(City::class, query:
fn ($q, $c) => $q->where('region_id', $c->parent())),
Change the country and the region clears; the region clearing is what clears the city. You do not wire that up.
Several parents
Options resolve once every declared parent has a value.
AjaxSelect::make('Model', 'model_id')
->parent('brand_id', 'year')
->options(fn ($c) => CarModel::query()
->where('brand_id', $c->parent('brand_id'))
->where('year', $c->parent('year'))
->pluck('name', 'id')),
Until then it resolves nothing, which keeps an unbounded query from running the first time the form renders. emptyWhenParentMissing(false) turns that off.
Inside actions
Action modals are ordinary dependent-field territory in Nova 5, so the field works there unchanged. There is no model being edited: $context->isAction() is true and $context->model() returns null.
Composing with Nova
Because parent() is built on dependsOn() rather than beside it, everything else composes.
AjaxSelect::make('City', 'city_id')
->parent('country_id')
->optionsFromModel(City::class, query:
fn ($q, $c) => $q->where('country_id', $c->parent()))
->hide()
->dependsOn('has_address', function (
AjaxSelect $field, NovaRequest $request, FormData $data
): void {
if ($data->boolean('has_address')) {
$field->show()->rules('required');
}
}),
Your dependsOn() callback runs after the options resolve, so it can inspect or override them.
The context
An options closure receives an AjaxSelectContext and the current NovaRequest. Declare only the arguments you want — fn () =>, fn ($c) => and fn ($c, $r) => are all valid.
| Member | Type | What it is |
| parent() | mixed | The first declared parent's value |
| parent('country_id') | mixed | A specific parent's value |
| parents() | array | All parent values, in declaration order |
| hasAllParents() | bool | Are they all filled in? |
| value / hasValue() | mixed / bool | What is currently selected |
| search / isSearching() | ?string / bool | The search term, when searching |
| limit | int | How many options to return |
| mode | string | create, update, attach, update-attached, action, index, detail |
| isAction() / isForm() | bool | Which kind of request this is |
| model() | ?Model | The resource being edited — null on create and in actions |
| user() | ?Authenticatable | The authenticated user |
Parent values always come from Nova's FormData, already restricted to the attributes this field declared. A resolver never reads raw request input.
Note. model() is a method, not a property, on purpose: resolving it costs a query most closures never need. It is memoized, so asking twice is free.
Option sources
->options(['draft' => 'Draft', 'live' => 'Live'])
->options(fn ($c) => $geoService->citiesByCountry($c->parent()))
->optionsFromModel(City::class, label: 'name', value: 'code')
->optionsFromRelation('members')
->optionsFromEnum(Status::class)
Each source answers three questions cheaply, which is why validation and index rendering never drag the whole option set into memory.
| Source | Resolve | Membership | One label |
| optionsFromModel() | one query, limited, search in SQL | whereKey()->exists() | ->value($label) |
| optionsFromRelation() | one query on the relation | whereKey()->exists() | ->value($label) |
| optionsFromEnum() | cases(), no I/O | tryFrom() | tryFrom() |
| options([...]) | in memory | in memory | in memory |
| options(fn () => ...) | your closure, memoized | resolve, then scan | resolve, then scan |
| get('/url') | the browser fetches | cannot know | cannot — field hides |
Accepted payload shapes
['gb' => 'United Kingdom'] // value => label map
[['value' => 1, 'label' => 'Toronto']] // Nova's own shape
[['value' => 1, 'display' => 'Toronto']] // the 1.x shape
[['id' => 1, 'name' => 'Toronto']] // an API payload
City::all() Status::cases() // models, enums
['options' => [...]] / ['data' => [...]] // envelopes
label wins over display when both are present. Canonical numeric strings are cast to integers exactly as Nova's own Select does, so '12' and 12 compare equal against an integer foreign key.
Searching
AjaxSelect::make('City', 'city_id')
->optionsFromModel(City::class)
->searchColumns('name', 'postal_code')
->asyncSearchable()
->minSearchLength(2)
->limit(25),
A model- or relation-backed field pushes the search into SQL across searchColumns(). Anything else filters in PHP after resolving — or give it searchUsing(), which keeps a model source's cheap membership and label lookups intact.
Until the term is long enough the field resolves nothing except the option already selected, so an edit form still shows its label. A search that happens not to include the stored value never silently clears it.
Index and detail
->labelFrom('city.name') // read it off the resource
->labelUsing(fn ($value, $resource) => ...) // resolve it yourself
->resolveLabelFromOptions() // ask the option source
labelFrom() is the one to reach for: a data_get() on the resource, so with the relation in your resource's $with it costs no extra query. resolveLabelFromOptions() is opt-in because it asks per value; it is memoized per request, so fifty rows over six cities cost six lookups.
If the field has no way to resolve a label it hides itself on index and detail rather than showing a bare id. Upgrading cannot make an existing screen worse.
Validation
Submitted values are checked against the resolved options automatically. A forged city_id is rejected even when the id exists, because the check runs against the submitted country.
->withoutOptionValidation()
->validateOptionsUsing(fn ($value, $c) => ...)
->optionValidationMessage('Pick a city in that country.')
The rule is lazy: one exists query rather than materializing every option on every save. It is skipped in endpoint mode, where the option set lives in your application's route.
Worth knowing. This stops a value that does not belong to the submitted parent. It cannot stop a consistent pair the user was never meant to see — scope the options query for that, as you would a Nova BelongsTo relatable query.
Caching
->cacheFor(3600)
->cacheScope(fn ($c) => tenant()->getKey())
->cacheTags(['geo'])
->cacheSearchResults()
->withoutCache()
The key is derived from the source, the field, the parent values, the limit and the locale. Searches bypass the cache by default, because one entry per keystroke is worse than an indexed LIKE.
The cache key is not scoped to the authenticated user or tenant. Scoping it that way by default would destroy the hit rate for fields that do not need it. If a field's options depend on who is asking, you must set cacheScope() — otherwise one tenant can be served another's options.
Security model
What the package defends, and what is deliberately left to you.
It owns no routes
Grep src/ for Route:: and you find nothing. Every resolution happens inside Nova's own field-sync endpoints, so Nova's form request authorizes the resource and the mode, rebuilds the resource and locates the field before an application closure is invoked. A package-owned endpoint would have to re-implement resource resolution and authorization by hand, which is where field packages tend to grow vulnerabilities.
What it defends
- Resolver input cannot be steered. Parent values come from
FormData, restricted to the declared attributes.
- Forged selections. A submitted value is checked against the options the field would offer for the parents submitted with it — one membership query, not a materialized
Rule::in.
- Search terms are bound, and
% and _ are escaped so a user cannot widen their own query to the whole table.
- Unbounded result sets are capped by
limit().
Deliberate, not oversights
The options cache is not scoped to the user or tenant by default. Scoping every entry by user would destroy the hit rate for fields whose options are global. If a field's options depend on the tenant or the viewer, set cacheScope(). This is the one foot-gun in the package.
Column names are code, not request data. searchColumns(), the label and value arguments, the relation name and the labelFrom() path are interpolated. They come from your resource definition; deriving one from a request is an injection this package does not defend against.
Endpoint mode validates nothing, on purpose. With get('/url') the option set lives in your route, so the field cannot know it. Validation is skipped rather than guessed.
Reporting
Email gabriele.sbaiz@noviasnet.it with a description, the version and a reproduction. Please do not open a public issue. Acknowledgement within 5 working days, assessment within 15.
Upgrading from 1.x
Nothing is required. Everything below is optional cleanup.
Nothing is required
get() and parent() keep working, the NovaAjaxSelect class and NovaAjaxSelectServiceProvider still resolve, and a legacy endpoint may keep returning {value, display}. Existing call sites also gain debounced and cancellable requests, a loading state, error reporting, and — because parent() now rides Nova's dependency machinery — working dependsOn(), hide(), show() and action-modal support.
1. Remove alexwenzel/ajax-select
Both packages registered Nova::script('ajax-select', ...). Nova resolves scripts by name with ->first(), so with both installed one bundle was never served and its field rendered as an unknown component. 2.0 uses a namespaced handle, but there is no reason to keep both.
2. Delete the routes
// before — plus a route in every tenant's routes file
AjaxSelect::make('City', 'city_id')
->get('/api/cities/country/{country_id}')
->parent('country_id')
// after
AjaxSelect::make('City', 'city_id')
->parent('country_id')
->optionsFromModel(City::class, query:
fn ($q, $c) => $q->where('country_id', $c->parent()))
->cacheFor(3600)
If the options come from a service rather than a model, the closure form takes the route body verbatim.
3. Delete the shadow detail fields
// before
AjaxSelect::make('City', 'city_id')
->get(...)->parent(...)->onlyOnForms(),
Text::make('City', 'city_id', fn () => $city?->name)->onlyOnDetail(),
// after
AjaxSelect::make('City', 'city_id')
->parent(...)
->optionsFromModel(...)
->labelFrom('city.name'),
4. Drop the canSee() workarounds
Any branch that exists only because dependsOn() did not fire on this field — typically if ($field instanceof AjaxSelect) return $field->canSee(...) — can go back to being an ordinary hide()->dependsOn(...).
Breaking changes
- PHP 8.3+, Laravel 12 or 13, Nova 5.
laravel/nova is now a real dependency rather than a dev one.
- The asset handle and the Vue component names are namespaced.
showOnIndex / showOnDetail are no longer hardcoded false — the field still hides itself when it cannot resolve a label, so no existing screen changes.
- Option validation is on by default, except in endpoint mode.
spatie/laravel-package-tools is no longer a dependency.
Deprecated and removed in 3.0: get() and endpoint mode, the NovaAjaxSelect and NovaAjaxSelectServiceProvider class names, and the {value, display} payload shape.
Contributing
The gates, and the two rules that matter most.
Getting set up
Nova is a paid, private Composer package, so you will need a licence.
composer config --global http-basic.nova.laravel.com \
"you@example.com" "your-licence-key"
composer install
npm ci && npm run build
composer test # Pest
composer format # Pint
npm test # Vitest
npm run build # compile dist/
There is no CI. Those four commands are the whole gate, so please run all of them.
Ground rules
- Every README example is a test.
tests/ReadmeExamplesTest.php executes the code samples in the documentation. Change a documented API and that file goes red first.
- Do not add a route. Every option resolution rides Nova's own field-sync request, which means Nova has already authorized the resource, the mode and the user. If you think you need an endpoint, the answer is almost certainly a new
OptionSource.
- Do not walk the Vue component tree. Parent changes arrive on Nova's field event bus through
getFieldAttributeChangeEventName(). Build the event name with that helper, never by hand.
- Every Nova import goes through
resources/js/nova.js, the single seam over Nova's internals.
dist/ is committed
A Nova field is unusable without its compiled bundle, and with no CI to build one on release the repository carries it. The build is deterministic, so rebuild and check for drift before tagging.
npm ci && npm run build && git diff --exit-code -- dist
1.x shipped a FormField.vue whose template had been replaced but whose script had not, so its searchable branch referenced methods that did not exist.