Building a Search-as-You-Type Autocomplete with Livewire and Eloquent
Autocomplete fields feel like they need a heavy JavaScript dependency, but Livewire handles the whole thing — debounced input, server-side querying, and keyboard navigation — with a single component. This tutorial builds a customer search field for a PhpArchitect internal admin panel, where support staff need to find a customer by name or email as they type.
Step 1: The component
php artisan make:livewire CustomerSearch
namespace App\Livewire;
use App\Models\Customer;
use Livewire\Component;
class CustomerSearch extends Component
{
public string $query = '';
public array $results = [];
public ?int $selectedIndex = null;
public ?Customer $selected = null;
public function updatedQuery(): void
{
$this->selectedIndex = null;
if (strlen($this->query) < 2) {
$this->results = [];
return;
}
$this->results = Customer::query()
->where('name', 'like', "%{$this->query}%")
->orWhere('email', 'like', "%{$this->query}%")
->limit(8)
->get(['id', 'name', 'email'])
->toArray();
}
public function select(int $customerId): void
{
$this->selected = Customer::find($customerId);
$this->query = $this->selected->name;
$this->results = [];
}
public function render()
{
return view('livewire.customer-search');
}
}
updatedQuery() is a Livewire lifecycle hook that fires automatically whenever the $query property changes — no manual event wiring needed.
Step 2: Debouncing the input
Without debouncing, every keystroke fires a network request. Livewire's wire:model.live.debounce handles this declaratively in the view:
<div class="relative" x-data="{ open: @entangle('results').live }">
<input
type="text"
wire:model.live.debounce.300ms="query"
wire:keydown.arrow-down="$set('selectedIndex', ($wire.selectedIndex ?? -1) + 1)"
wire:keydown.enter="select(results[selectedIndex]?.id)"
placeholder="Search customers by name or email..."
class="w-full rounded-md border-gray-300"
autocomplete="off"
/>
@if (count($results))
<ul class="absolute z-10 mt-1 w-full rounded-md border bg-white shadow-lg">
@foreach ($results as $index => $customer)
<li
wire:key="customer-{{ $customer['id'] }}"
wire:click="select({{ $customer['id'] }})"
class="cursor-pointer px-3 py-2 {{ $index === $selectedIndex ? 'bg-indigo-50' : '' }}"
>
<span class="font-medium">{{ $customer['name'] }}</span>
<span class="text-sm text-gray-500">{{ $customer['email'] }}</span>
</li>
@endforeach
</ul>
@endif
</div>
The 300ms debounce is the sweet spot for a search-as-you-type field: fast enough to feel responsive, slow enough that a fast typist isn't firing a request per keystroke.
Step 3: Indexing the query for scale
LIKE '%term%' queries can't use a standard B-tree index because the leading wildcard prevents an index seek — MySQL and Postgres both fall back to a full table scan. That's fine for a few thousand customers, but once the table grows, add a full-text index instead:
Schema::table('customers', function (Blueprint $table) {
$table->fullText(['name', 'email']);
});
$this->results = Customer::query()
->whereFullText(['name', 'email'], $this->query)
->limit(8)
->get(['id', 'name', 'email'])
->toArray();
->toArray();
Full-text search also gets you relevance ranking and multi-word matching for free, which a LIKE clause can't offer.
Step 4: Handling the empty state and loading indicator
A subtle but important detail: show a loading indicator during the network round trip so the field doesn't feel unresponsive on a slow connection.
<div wire:loading.delay wire:target="query" class="absolute right-3 top-2.5">
<svg class="h-4 w-4 animate-spin text-gray-400"><!-- spinner --></svg>
</div>
@if ($query !== '' && count($results) === 0 && ! $selected)
<p class="mt-1 text-sm text-gray-500">No customers matched "{{ $query }}".</p>
@endif
wire:loading.delay only shows the spinner if the request takes longer than a short threshold, avoiding a flicker on fast responses.
Wrapping up
That's a fully working autocomplete: debounced input, indexed server-side search, keyboard navigation, and loading/empty states — in one Livewire component and a Blade view, with no separate JavaScript build step. From here, the natural next steps are caching frequent queries, scoping results to the authenticated user's permissions, and adding a "recently viewed" fallback list when the query is empty.