razorcreations/ajax-field
A Laravel Nova field that loads and updates its options over AJAX, making it easy to build dependent form fields—such as country/state or category/subcategory selects—where one field's value triggers a server request to refresh another's contents.
Verify Nova 3 or 4 before you write a line of code. composer show laravel/nova — if you're on Nova 5, stop; it's not a declared target. Then pin the package exactly ("razorcreations/ajax-field": "1.*" or a fixed tag) rather than floating, since a routine composer update shouldn't be able to change admin behaviour in a single-maintainer, 13-star package.
composer require razorcreations/ajax-field
# Check composer.json / README before assuming this is unnecessary:
php artisan nova:asset razorcreations/ajax-field
Read three files before trusting the README. It's typically one drifted example block. The real contract lives in:
| File | Confirm |
|---|---|
src/AjaxField.php |
Real namespace and fluent setters (optionsUrl(), placeholder, search flags). A wrong use in fields() is a fatal error. |
resources/js/components/Field.vue |
The query parameter name it sends (usually q or search) and the exact JSON shape it expects back. |
composer.json |
Nova/PHP constraints and whether JS assets ship prebuilt in dist/. |
The two unknowns that burn the most time are the search-term key and the response shape. Both fail silently — an empty dropdown, not an exception.
First use case: replace one slow relationship select. Pick a resource whose BelongsTo renders thousands of <option> nodes.
// app/Nova/Post.php — verify namespace against src/
use Razorcreations\AjaxField\AjaxField;
public function fields(Request $request)
{
return [
AjaxField::make('Author', 'author_id')
->optionsUrl(route('nova.authors.search'))
->placeholder('Search authors…'),
];
}
Prove it on a ~50 row table first. That confirms the component renders your payload and that the field hydrates the already-attached record on the edit form — a record that will never appear in a search result. Then point it at the large dataset and measure both sides: initial form load (should drop) versus keystroke-to-result latency (a brand new cost that was previously zero).
Register the endpoint behind Nova's middleware group, never a public route group.
Route::middleware(['nova'])->get('/nova-api/authors/search', AuthorSearchController::class);
Nova's auth, session and CSRF middleware must apply or you'll get intermittent 419/403 after sessions rotate — the kind of bug that works all day in testing and fails the next morning.
Three rules in every search controller:
public function __invoke(Request $request)
{
$search = trim((string) $request->input('q')); // verify key in Field.vue
return User::query()
->where('team_id', $request->user()->team_id) // scope to policy
->when($search !== '', fn ($q) => $q->where(
fn ($w) => $w->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
))
->orderBy('name')
->limit(20)
->get(['id', 'name as label']); // enforce shape in SQL
}
Cap the result set (it's a picker, not a grid), select only the columns you send (never select * into a JSON label payload), and alias label in SQL so the frontend contract is enforced server-side rather than in a map() someone forgets to update.
One whitelisted endpoint instead of a controller per field. Past two AjaxFields, a config-keyed single endpoint keeps route count flat. The key must resolve through a server-side allowlist — never instantiate a class from request input.
$spec = config('nova-ajax-search.fields.' . $request->input('field'));
abort_unless($spec, 404);
return $spec['model']::query()
->when($request->input('q'), fn ($q, $s) => $spec['apply']($q, $s))
->limit(20)
->get(['id', $spec['label'] . ' as label']);
That config map is your extension point for adding fields without touching routes, and the natural place to record which fields are authorised for which Nova resources.
Treat it strictly as a BelongsTo replacement. It writes an ID into an existing column. Leave the migration, column and relationship untouched so exists:users,id validation, model events and Nova's display path keep working. "Type to create a new record" is a different component — don't bend this one into it.
Scope queries server-side to mirror your Nova policy. The field knows nothing about your NovaResource authorization. A permissive search endpoint is the single most realistic data-leak failure mode here: it exposes a raw query surface to anyone who can reach the route.
Decide up front how the selected label resolves on edit. Create screens have nothing selected; edit screens must turn an ID into a label without a search. Either the component accepts an initial option via prop, or you add a resolveUsing-style hook in a small subclass. Test sequence: create a record, reopen it, then reopen after clearing the query — the label must survive both.
Document the native fallback in the same PR. Note in the resource docblock which native field replaces it (BelongsTo::make()->searchable() covers a surprising number of Nova 4 cases). That one sentence turns a future upgrade from archaeology into a bounded task.
Silent empty dropdown is the default failure mode. A 200 with the wrong JSON shape is indistinguishable from "no matches". Debug in this order: (1) php artisan route:list | grep search to confirm route and middleware; (2) hit the URL directly as a logged-in user and inspect the raw payload; (3) diff that shape against Field.vue; (4) only then read your controller. The query is rarely the problem.
419 and 403 are middleware bugs, not field bugs. If the endpoint sits outside Nova's group, CSRF and auth will bite you after session rotation. A 419 on an AJAX call is easy to miss because it never lands in laravel.log as an exception — check the network tab.
Every keystroke is a query unless you throttle. AJAX search moves load from page render to the endpoint; it doesn't delete it. Before broad rollout, confirm in Field.vue whether there's a debounce, a minimum query length, and cancellation of stale in-flight requests. Missing any of these is typically a five-line component patch and worth doing rather than absorbing the load. Add ->limit(20) regardless — the component may not paginate.
Index what you search — and watch the leading wildcard. LIKE '%term%' cannot use a B-tree index. Invisible at a few thousand rows; at hundreds of thousands the "fast" AJAX field feels slower than the dropdown it replaced. Prefer prefix search (LIKE 'term%', indexable) or whereFullText/Scout, accepting that the behaviour diverges from substring matching.
Verify the asset deploy path into a fresh environment. Community Nova fields often ship prebuilt JS in dist/, but not always — and the README may not say. If assets aren't prebuilt and no Node build ran, you'll ship a field that renders a blank input in production while working perfectly locally.
Config quirks live in Vue, not PHP. Whether the search box clears after selection, whether focus sends an empty query and returns the first 20 rows, and how the selected label renders are all component-side. Read Field.vue end to end once — ten minutes that replaces a day of trial-and-error, and the right place to add behaviour the package doesn't expose before you consider forking.
Treat a fork as the plan, not the surprise. Single maintainer, 13 stars, zero dependents, explicit Nova 3/4 target — Nova 5 is the likely end of the line. Guardrails: exact version constraint, a named internal owner, and a CI test that loads the Nova resource and hits the search endpoint (catches both middleware drift and Nova upgrades). The code is small enough that forking is a morning's work, so the real risk is not noticing rather than not being able to fix it.
How can I help you explore Laravel packages today?