Reader Stacks

Building an Autocomplete Search Field in Laravel

The backend piece — a debounced, rate-limited JSON search endpoint — stays the same regardless of which frontend autocomplete widget consumes it, and that endpoint is the part actually worth getting right.

An autocomplete field is really two separate concerns: a backend endpoint that returns matching results as JSON, and a frontend widget that calls it as the user types. The frontend library choice (a jQuery plugin, a native <datalist>, a JS framework component) changes over time far more than the backend pattern does — the endpoint below works with any of them.

1. The search endpoint

Route::get('/api/products/search', [ProductSearchController::class, 'search']);
class ProductSearchController extends Controller
{
    public function search(Request $request)
    {
        $query = trim((string) $request->query('q', ''));

        if (mb_strlen($query) < 2) {
            return response()->json([]); // avoid a wide-open match on 0-1 characters
        }

        $products = Product::where('name', 'like', '%'.$query.'%')
            ->limit(10)
            ->get(['id', 'name']);

        return response()->json($products);
    }
}

Requiring a minimum query length (two characters here) before hitting the database at all avoids running an expensive, near-unconstrained LIKE '%%' query on every keystroke starting from an empty or single-character input — a small guard that matters more than it looks once real traffic is involved.

2. Debouncing on the frontend

let debounceTimer;
searchInput.addEventListener('input', (e) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        fetchResults(e.target.value);
    }, 300); // wait 300ms after the user stops typing before firing the request
});

Without debouncing, every single keystroke fires a separate request — for a five-character search term, that's five near-simultaneous requests, most of which are wasted since only the final one's result actually matters to the user. A short delay after the last keystroke, cancelling any pending request before it fires, is standard practice for this exact reason.

3. Rate limiting the endpoint

Route::middleware('throttle:30,1')->get('/api/products/search', [ProductSearchController::class, 'search']);

Even with debouncing on a well-behaved frontend, a search endpoint is still a public, unauthenticated (or lightly authenticated) route that can be hit directly and repeatedly — rate limiting it (here, 30 requests per minute) is a reasonable safeguard against both accidental abuse and a scripted scraper.

4. Full-text search for larger datasets

Product::whereFullText('name', $query)->limit(10)->get();

A plain LIKE '%query%' can't use a standard index efficiently (the leading wildcard prevents it) and becomes a genuine performance problem once a table reaches a substantial size. whereFullText() (requiring a full-text index on the column via a migration) scales considerably better and, as a side benefit, supports relevance-ranked matching rather than plain substring matching.

5. Wiring up whichever frontend widget is chosen

async function fetchResults(query) {
    const response = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`);
    const results = await response.json();
    renderSuggestions(results); // populate the dropdown, however the chosen widget expects
}

Whether the actual dropdown UI comes from a lightweight vanilla-JS render function like this, a native <datalist>, or a dedicated JS component library, the contract with the backend stays identical: send a query string, get back a small JSON array of matches. This is what makes the backend endpoint the durable, reusable part of the feature.

6. Returning more than just id and name

$products = Product::where('name', 'like', '%'.$query.'%')
    ->limit(10)
    ->get(['id', 'name', 'price', 'image']);

Explicitly selecting only the columns actually needed for the dropdown (rather than a bare get(), which pulls every column) keeps the response payload small — worth doing deliberately for an endpoint that fires frequently while the user is actively typing.

Topics: Pagination & Filtering APIs & Integrations