A search box does not have to reload the entire page every time someone submits a query. The browser can ask PHP for a small JSON response, then replace just the result list. This is an AJAX workflow, and it is a useful starting point for understanding how a frontend and a backend cooperate.
In this demo, you will search a fictional three-product catalog. There is no database and no login. That keeps the request, response, and rendering steps visible before you introduce SQL, authentication, or a framework. The example uses the browser’s Fetch API rather than jQuery, so you do not need to install a JavaScript library.
Download the complete PHP AJAX demo, or copy the three files below into one folder. The PHP code requires PHP 8.1 or later because its response helper uses the never return type.
What happens when you click Search
The form first captures the search text. JavaScript stops the ordinary form navigation, sends a GET request to search.php, and waits for JSON. PHP filters a fixed array and returns matching products. JavaScript creates list items and inserts them into the existing page. The document URL stays the same; only the result area changes.
AJAX historically means Asynchronous JavaScript and XML, but this example exchanges JSON. Fetch is a promise-based browser interface for HTTP requests. It is the request mechanism here; PHP still runs on the server. You can read the browser behavior in MDN’s Fetch guide.
1. Create the page: index.html
The form has a labelled search field, a submit button, and a separate status message. The result list starts empty. The script uses defer, so it runs after the HTML has been parsed. The inline styles are only there to keep the demonstration readable; they are not a dependency.
Keep index.html, app.js, and search.php in the same folder. The form’s action and the JavaScript endpoint are relative URLs. Without JavaScript, submitting the form opens the raw JSON response instead of providing inline results.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AJAX + PHP product search demo</title>
<style>
body { margin: 40px auto; padding: 0 20px; max-width: 680px; font: 16px/1.6 system-ui, sans-serif; color: #183426; }
form { display: grid; gap: 12px; }
input, button { padding: 12px; font: inherit; border: 1px solid #9eb3a4; border-radius: 8px; }
button { background: #183426; color: white; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
#status { min-height: 26px; }
li { padding: 12px 0; border-bottom: 1px solid #dce5dc; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>Search the demo catalog</h1>
<p>Try <strong>lamp</strong>, <strong>notebook</strong>, or <strong>cable</strong>. Leave the search empty to list all three products.</p>
<form id="searchForm" action="search.php" method="get">
<label for="query">Product name</label>
<input id="query" name="q" type="search" maxlength="60" placeholder="For example: lamp">
<button id="searchButton" type="submit">Search</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
<ul id="results" aria-label="Matching products"></ul>
<noscript>JavaScript is required for the inline results. The form will show the endpoint's JSON response without JavaScript.</noscript>
</main>
</body>
</html>
2. Return JSON from PHP: search.php
This endpoint accepts GET because searching the catalog only reads data. It returns HTTP 405 for other methods, and HTTP 422 when q is an array or exceeds 60 bytes. The field limit is checked on the server even though the page also has a client limit. For this ASCII sample catalog, the search uses a case-insensitive substring comparison.
The catalog contains invented product IDs and names. A query of lamp finds Desk Lamp; an empty query returns all three products. The query is not a SQL expression, and the example does not connect to a database.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function sendJson(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
header('Allow: GET');
sendJson(['ok' => false, 'error' => 'Use a GET request.'], 405);
}
$query = $_GET['q'] ?? '';
if (!is_string($query) || strlen($query) > 60) {
sendJson(['ok' => false, 'error' => 'Search must be text of 60 bytes or fewer.'], 422);
}
$query = trim($query);
$products = [
['id' => 'demo-101', 'name' => 'Notebook', 'category' => 'Stationery'],
['id' => 'demo-102', 'name' => 'Desk Lamp', 'category' => 'Home office'],
['id' => 'demo-103', 'name' => 'USB-C Cable', 'category' => 'Accessories'],
];
$matches = array_filter($products, static function (array $product) use ($query): bool {
return $query === '' || stripos($product['name'], $query) !== false;
});
sendJson(['ok' => true, 'products' => array_values($matches)]);
array_filter keeps matching rows but can leave gaps in numeric keys. Calling array_values before encoding ensures products is a JSON array, including when only the second product matches. Without that reindexing, PHP may encode a numerically keyed result as an object, which would break the frontend’s array check.
The response declares application/json and is produced with json_encode. Do not assemble JSON by concatenating quotes around values. The encoding flag makes encoding failures explicit. The demo serializes fixed UTF-8 catalog strings; a real application must also handle encoding failures from its own data. See the PHP json_encode reference.
3. Make the AJAX request: app.js
The submit handler prevents a full-page navigation, disables the button while the request is in progress, and clears old results. URLSearchParams encodes the search value, so you do not have to concatenate unescaped input into the query string. A ten-second timer aborts a request that has not completed.
'use strict';
const form = document.getElementById('searchForm');
const query = document.getElementById('query');
const button = document.getElementById('searchButton');
const status = document.getElementById('status');
const results = document.getElementById('results');
form.addEventListener('submit', async event => {
event.preventDefault();
if (button.disabled) return;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
const parameters = new URLSearchParams({ q: query.value.trim() });
button.disabled = true;
query.readOnly = true;
form.setAttribute('aria-busy', 'true');
status.textContent = 'Searching…';
results.replaceChildren();
try {
const response = await fetch(`search.php?${parameters}`, {
headers: { Accept: 'application/json' },
signal: controller.signal
});
const type = (response.headers.get('content-type') || '').split(';')[0].trim();
if (type !== 'application/json') {
throw new Error('Expected JSON. Check that PHP is running and the endpoint URL is correct.');
}
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
if (data.ok !== true || !Array.isArray(data.products)) {
throw new Error('The response does not match the expected product list.');
}
const fragment = document.createDocumentFragment();
for (const product of data.products) {
if (!product || typeof product.name !== 'string' || typeof product.category !== 'string') {
throw new Error('A product has unexpected fields.');
}
const item = document.createElement('li');
item.textContent = `${product.name} — ${product.category}`;
fragment.appendChild(item);
}
results.appendChild(fragment);
status.textContent = data.products.length
? `Found ${data.products.length} product(s).`
: 'No products found. Try a different name.';
} catch (error) {
status.textContent = error.name === 'AbortError'
? 'The request timed out after 10 seconds. Try again.'
: error.message;
} finally {
clearTimeout(timer);
button.disabled = false;
query.readOnly = false;
form.removeAttribute('aria-busy');
}
});
There are three different checks after Fetch returns: the response media type, the HTTP success status, and the expected payload structure. An HTTP error such as 404 does not by itself reject the Fetch promise. A server can also return an HTML error page where you expected JSON. Checking these boundaries makes the failure easier to understand than a generic “AJAX failed” message.
Rendering uses textContent rather than putting product data into innerHTML. That makes a product name ordinary displayed text instead of interpreting it as markup. A document fragment collects the rows before insertion. The finally block restores the controls after success, invalid responses, or timeout.
4. Run it through PHP, not by double-clicking the HTML
Open a terminal in the folder containing the three files and run:
php -S 127.0.0.1:8000
Then visit http://127.0.0.1:8000/. Opening the HTML as a file:// document does not execute PHP. If you use XAMPP, place the folder under its configured document root, start Apache, and open the folder through its localhost URL instead.
The PHP built-in server is for local development and controlled testing. Use a properly configured web server when deploying a public application. Its behavior and limits are documented in the PHP built-in server manual.
5. Check the behavior, not just the happy path
| Request or action | Expected result |
|---|---|
| Search for lamp | One result: Desk Lamp — Home office |
| Submit an empty search | All three fictional products |
| Search for an unknown name | No products found; products is an empty array |
| Open search.php?q[]=lamp directly | HTTP 422 with a JSON error |
| Send POST to search.php | HTTP 405 with Allow: GET |
The downloadable example was checked against these endpoint cases using PHP 8.2. Browser checks covered successful results and the empty-results message. These observations concern this small demo, not a performance or security assessment of a production catalog.
Use the browser’s Network panel to inspect the request URL, HTTP status, response headers, and JSON. Paste a redacted response into the Toolzify JSON Formatter when you want to inspect the structure. If you receive HTML instead, follow the unexpected HTML response debugging workflow; formatting will not turn an HTML error page into the expected API response.
Before connecting a real database
Replace the fixed array with a bounded database query using prepared statements. Validate the search length, cap the result count, and add appropriate request limits. If the records are private, authorize access on the server; hiding a button in JavaScript does not restrict an endpoint.
This demo does not create, update, or delete records. Those actions need a suitable write method, server-side validation, authorization, and CSRF protection for cookie-authenticated flows. Keep the frontend and PHP endpoint on the same origin for this initial example instead of introducing cross-origin permissions before you need them. In a deployed application, log PHP errors privately and return a deliberate error response rather than exposing warnings inside JSON.
Once the three-file version works, keep its request and response shape as a small integration fixture. You can change the visual design or add a database while retaining a clear way to check which part failed: the PHP response, the HTTP request, or the browser’s rendering.
Comments (0)
Join the discussion