Laravel Magazine
Building a Search-as-You-Type Autocomplete with Livewire and Eloquent

Building a Search-as-You-Type Autocomplete with Livewire and Eloquent

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.