PHP Live Search with AJAX

Build PHP live search with debounced fetch requests, prepared SQL, bounded results, safe DOM rendering, and protection against stale responses.

Lesson 64 of 86Intermediate12 min read

Live search feels simple: type a few letters and matching products appear. Behind that behavior are several small decisions that determine whether the feature is reliable. You need to avoid querying on every keystroke, keep values separate from SQL, cap the result count, and prevent an older response from replacing a newer one. This reference connects a same-origin PHP JSON endpoint to a debounced browser search. The database example uses PDO and MySQL, while the JavaScript uses fetch(). You will understand both the SQL behavior and the interface states rather than treating autocomplete as a mysterious library feature.

Define what a search means

Decide whether you are searching a prefix, a contained phrase, or words under a full-text engine. A prefix search for 'pen' can match Pencil; a contains search can also match Open notebook. Case sensitivity and accent handling depend on the database collation, so test the language your catalog uses. LIKE interprets wildcard characters, meaning a submitted percent or underscore has special meaning unless you escape it under a documented policy. If the interface promises literal text, escape these wildcard characters separately from parameter binding. Prepared statements prevent SQL injection but do not change LIKE's pattern grammar. Keep maximum term length and result count small, and choose a deterministic order with a unique tie-breaker.

Create a narrow endpoint contract

A read-only GET endpoint can accept q as a scalar string and return an array of small result objects. Reject unexpected arrays, trim permitted edge whitespace, and impose a maximum length. A minimum length such as two characters reduces broad scans, but choose a policy appropriate to your data and users. Return an empty result array for a valid term below the minimum if that is the documented interface behavior. Do not expose private product fields or account records through a public search. If results are permissioned, apply authorization inside the query or before serialization. Set Content-Type to JSON and return status codes that distinguish invalid input from server failure. Keep SQL and connection diagnostics out of the public body.

Use prepared SQL and bounded results

The PHP endpoint prepares a SELECT with a placeholder for the search pattern. The pattern is data, so it belongs in the execute values rather than being joined into the SQL text. Select only the columns needed by the list and use a fixed or safely bound integer limit. ORDER BY name, id gives stable behavior when names repeat. A prefix LIKE may use an index under suitable collation and query conditions; a leading wildcard often prevents ordinary prefix index use. Measure the actual plan rather than promising an index always helps. For large catalogs, consider a dedicated full-text or search service with an explicit ranking and access policy. The UI's small result cap does not automatically make the underlying query cheap.

Debounce input and cancel obsolete reads

Debouncing waits briefly after the most recent input event before starting a request. Each new event clears the earlier timer. That reduces requests during rapid typing without making users manually press a search button. AbortController can cancel an obsolete browser request, but the server may already be processing it. Use a monotonically increasing sequence number as an additional rendering guard: a response may update the list only if it belongs to the current search. Encode query values with URLSearchParams rather than concatenating unescaped text into a URL. Check response.ok before reading results as success. Ignore expected aborts while displaying genuine failures clearly. The exact debounce delay is an interface choice, not a security boundary.

Render accessible, untrusted result text

Create list items with document.createElement() and set textContent from the JSON name. Database content can include hostile markup, so inserting it into innerHTML creates a separate vulnerability even with prepared SQL. Show loading, no results, and failure states distinctly. A simple search result list is easier to implement accessibly than a full combobox; if you build keyboard-selectable autocomplete, follow its focus, arrow-key, and announcement requirements. Keep the search field labeled and indicate updated result counts through a suitable status region. Preserve a normal search-submit route for users who cannot use JavaScript when practical. Do not move keyboard focus after every response or repeatedly announce an entire large list.

Protect the service from needless work

Server-side rate limits, request budgets, and query monitoring protect against clients that ignore your debounce code. Cache public searches only where their data and freshness rules permit it. Authenticated results must not leak through shared caches. Log query duration and result counts without storing sensitive terms unnecessarily. Validate resource usage with popular prefixes, empty terms, wildcard-heavy input, and unusually long strings. Search pagination and result ranking should agree with the broader catalog page; a live-search preview may legitimately show only ten results and link to the complete search. Document that distinction so users do not assume missing preview items do not exist. Review slow searches using realistic data volume rather than a five-row development database.

Practice task and worked solution

Implement a browser widget with a labeled search input and a result list. On input, clear the prior debounce timer and schedule a request after a short documented delay. Abort the previous controller, increment a sequence number, build q using URLSearchParams, and check the HTTP result. The solution renders only if its sequence is still current and inserts names with textContent. Test typing Pe and then Pencil while deliberately slowing the first response. Also test no matches, an invalid request, and a network failure. Add a server limit because browser timing can be bypassed. For private catalogs, confirm one account cannot retrieve another account's products.

Complete browser widget

Save this HTML page beside the PHP search endpoint and serve both through the same PHP-enabled development server. The endpoint route below is /search.php; adapt it to your application's routing. The static training website displays these examples and does not itself host their PHP endpoints. An input event invalidates the previous request immediately, so an old result cannot render during the next debounce interval. A controller cancels unnecessary browser reads, while the sequence check remains the final guard. Result names enter the document through textContent.

HTML
<label for="product-search">Search products</label>
<input id="product-search" type="search" autocomplete="off">
<p id="search-status" role="status" aria-live="polite"></p>
<ul id="search-results"></ul>
<script>
const input = document.querySelector('#product-search');
const status = document.querySelector('#search-status');
const results = document.querySelector('#search-results');
let timer;
let controller;
let sequence = 0;
input.addEventListener('input', () => {
  clearTimeout(timer);
  controller?.abort();
  const current = ++sequence;
  const term = input.value.trim();
  results.replaceChildren();
  if (term.length < 2) {
    status.textContent = 'Type at least two characters.';
    return;
  }
  status.textContent = 'Waiting to search...';
  timer = setTimeout(async () => {
    controller = new AbortController();
    status.textContent = 'Searching...';
    try {
      const query = new URLSearchParams({q: term});
      const response = await fetch('/search.php?' + query, {
        signal: controller.signal,
        headers: {Accept: 'application/json'}
      });
      if (!response.ok) throw new Error('Search failed');
      const data = await response.json();
      if (current !== sequence) return;
      if (!Array.isArray(data.results)) throw new Error('Invalid response');
      for (const product of data.results) {
        if (typeof product.name !== 'string') throw new Error('Invalid product');
        const item = document.createElement('li');
        item.textContent = product.name;
        results.append(item);
      }
      status.textContent = data.results.length
        ? data.results.length + ' matching products.' : 'No matching products.';
    } catch (error) {
      if (error.name === 'AbortError' || current !== sequence) return;
      results.replaceChildren();
      status.textContent = 'Search unavailable. Please try again.';
    }
  }, 250);
});
</script>

Expected behavior: typing Pe waits 250 milliseconds after the latest input, requests the PHP endpoint, and lists matching names. Typing a newer term immediately clears old results and prevents the older response from rendering. A failed HTTP response produces the visible retry message. This is a results list with a status region; a keyboard-selectable combobox would require additional interaction behavior.

Continue learning

Read PDO and MySQL, JSON, and REST APIs for database and response contracts.

The PDO prepared statement reference documents placeholders for values.

PHP Live Search with AJAX Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A bounded PDO prefix search

The endpoint assumes a configured PDO MySQL connection in $pdo, a products table with id and name, and MySQL LIKE escape syntax. It treats percent, underscore, and ! literally.

With products Pen and Pencil, q=Pe returns those matching rows in name-and-ID order. q=% is below this example's minimum and returns an empty list; q=Pe% searches for a literal percent after Pe. IDs and rows depend on the database fixture. No PHP runs in the browser.

PHP
<?php
header('Content-Type: application/json; charset=UTF-8');
$q = $_GET['q'] ?? '';
if (!is_string($q) || strlen($q) > 80) {
    http_response_code(422);
    echo json_encode(['error' => 'Invalid search term']); exit;
}
$q = trim($q);
if (strlen($q) < 2) {
    echo json_encode(['results' => []]); exit;
}
$literal = strtr($q, ['!' => '!!', '%' => '!%', '_' => '!_']);
$stmt = $pdo->prepare(
    "SELECT id, name FROM products WHERE name LIKE ? ESCAPE '!' ORDER BY name, id LIMIT 10"
);
$stmt->execute([$literal . '%']);
echo json_encode(['results' => $stmt->fetchAll(PDO::FETCH_ASSOC)], JSON_THROW_ON_ERROR);
Output
Database-dependent: up to 10 matching products in name and ID order.

PHP Live Search with AJAX FAQ

Does debounce secure the server?

No. Debouncing waits until the user pauses typing, which only reduces normal client requests. Anyone can still call the search endpoint directly as often as they like, so the server needs its own limits, such as a minimum query length, a result limit and rate limiting.

Does prepare make percent literal?

No. A prepared statement keeps the value out of the SQL structure, but inside a LIKE pattern % and _ still act as wildcards. LIKE wildcard escaping is separate: escape those characters in the search text, and declare the escape character if your database needs it.

Why do results jump backward?

Requests can finish in a different order from the one in which they were sent. An old response for "ca" may finish last and replace the results for "cat". Cancel earlier requests with an AbortController, or ignore any response that doesn't match the latest query.

Can a leading wildcard use my index?

Often not. A pattern such as LIKE '%lamp%' can't use ordinary prefix lookup in a B-tree index, so the database may scan every row. LIKE 'lamp%' can use the index. Inspect the actual plan with EXPLAIN, and consider full-text search for searching inside text.

PHP Live Search with AJAX Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

Your widget uses debounce but receives thousands of direct requests. What is needed?

PHP Live Search with AJAX Quiz

2 questions to check what you learned.

  1. What prevents an older response from rendering after a newer term?

    • Debouncing keystrokes without checking response order
    • Only a SQL transaction
    • A current-request sequence check
    • Assuming fetch responses finish in request order
  2. What makes percent literal in a LIKE search?

    • Prepared statements alone
    • The JSON header
    • Debounce alone
    • Pattern escaping under a defined SQL escape policy