kerrialnewham/autocomplete
Laravel autocomplete package that makes it easy to add typeahead-style search to your app. Configure searchable model fields, query matching records, and return JSON results ready for any frontend autocomplete widget, with minimal setup and a clean, framework-friendly API.
Read this first: the package is a Symfony bundle. kerrialnewham/autocomplete ships a Symfony bundle class, a Twig form helper, and a JSON endpoint, plus a Stimulus controller for the browser side. In a Laravel app, nothing in the PHP half will run — Laravel has no bundle registration, no services.yaml, no Twig. What you can reuse is the Stimulus controller and the request/response contract it speaks. Treat this package as a reference implementation to extract from, not a dependency to composer require.
Minimal path to a working search box in Laravel:
assets/ or src/Resources/assets/ in the repo). This is the real spec — note the controller identifier, its static values (url, minLength, delay, maybe queryParam), and the exact JSON shape it expects back. Do not guess this; read it.resources/js/controllers/autocomplete_controller.js and fix the import path if it references a Symfony asset path.resources/js/bootstrap.js, or @hotwired/stimulus + Vite glob import).data-controller, data-autocomplete-url-value, and the target attributes the controller expects.First real use case: a "find customer" box on an order-creation form. Type ≥2 chars → fetch /api/customers/search?q=… → render suggestions → clicking one writes the customer ID into a hidden field the form submits.
{{-- resources/views/components/autocomplete.blade.php --}}
<div data-controller="autocomplete"
data-autocomplete-url-value="{{ $url }}"
data-autocomplete-min-length-value="2">
<input type="text"
data-autocomplete-target="input"
autocomplete="off"
class="form-input w-full" />
<input type="hidden" name="{{ $name }}" data-autocomplete-target="hidden" />
<ul data-autocomplete-target="results" class="border rounded shadow"></ul>
</div>
Own the endpoint. Match the contract exactly. The controller is a thin client; the PHP is where all your leverage lives. Return the same keys the controller reads and nothing more:
Route::get('/api/customers/search', function (Request $request) {
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['results' => []]);
}
$customers = Customer::query()
->where('tenant_id', auth()->user()->tenant_id)
->where(fn ($query) => $query
->where('name', 'like', "{$q}%")
->orWhere('email', 'like', "{$q}%"))
->orderBy('name')
->limit(10)
->get(['id', 'name', 'email']);
return response()->json([
'results' => $customers->map(fn ($c) => [
'value' => $c->id,
'text' => "{$c->name} ({$c->email})",
]),
]);
})->middleware(['auth'])->name('api.customers.search');
This is the entire reason to use server-side rendering over a client-side index: authorization, tenant scoping, and "search only records this user may see" happen in one place, in PHP, with your existing policies.
Wrap it in a Blade component. One <x-autocomplete> component used fifty times beats fifty hand-written data-attribute blocks, and gives you a single place to fix target names when you upgrade:
<x-autocomplete
name="customer_id"
:url="route('api.customers.search')"
:value="old('customer_id', $order->customer_id ?? null)"
:label="old('customer_name', $order->customer->name ?? null)" />
Have the component accept a label/value pair so edit forms render the already-selected item instead of an empty box.
Bind the ID, display the label. Never submit the visible text as the value. The hidden input is the source of truth; the visible input is ephemeral. On validation failure, old() must repopulate both or your users will lose their selection on every failed submit — feed the label back in from the relationship, as above.
Livewire / Alpine coexistence. If the enclosing form is a Livewire component, put wire:ignore on the autocomplete wrapper so Livewire's DOM diffing doesn't blow away the rendered suggestion list mid-fetch. Then bridge the hidden input back with wire:model on the hidden field, or dispatch a Livewire event from a custom select action in the controller.
Split the endpoint from the page. Keep the search route in routes/api.php (or a dedicated prefix) with throttle:60,1, so a held-down keystroke can't hammer your DB. The Stimulus delay value is a UX affordance, not a rate limit.
Cache aggressively when the data is stable. A Cache::remember("ac:{$tenant}:{$q}", 60, …) around the query is a one-line win on a hot search box, and the TTL gives you a natural staleness ceiling when a record is renamed.
composer require kerrialnewham/autocomplete in Laravel gets you a Bundle class that never registers and Twig templates you can't render. Copy the JS, don't require the package at runtime.autocomplete, the attribute is data-autocomplete-target="results" and data-autocomplete-url-value="…". Rename the file to customer-search_controller.js and every attribute silently stops working — Stimulus fails quietly, with a console warning at best.X-CSRF-TOKEN from a meta tag. The Symfony original assumed its own session/CSRF story; verify what the controller actually sends before you assume GET.AbortController keyed to the latest term, add one — it's ten lines and it removes a whole class of "wrong result got clicked" bugs.LIKE '%term%' won't use an index. Leading-wildcard matching is a full scan. Use term% where the UX allows, or move to a Postgres trigram / FULLTEXT index / Scout driver once the table grows past a few thousand rows. This is the first thing that will fall over in production.text field. If it uses innerHTML, an XSS payload in a customer name becomes a stored vulnerability. Check the DOM-write call in the controller source, and replace it with textContent if needed.role="combobox", no aria-activedescendant, no arrow-key handling. Budget a day to add keyboard navigation and screen-reader attributes yourself; the package will not have solved this for you.import that resolves outside your Vite entrypoints, or the build fails with an opaque "failed to resolve" error.meta.total, avatar_url) and grow into them later without touching the search route's consumers. Keep the contract documented in the route's docblock.How can I help you explore Laravel packages today?