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.