Skip to content

Lab 8: Live Search with AJAX

In this lab, you will implement a live search feature for your e-commerce application that filters products in real-time as users type. This pattern, also called “search-as-you-type”, eliminates the need for a separate “Search” button and provides instant feedback.

Live search is used on platforms like Amazon, eBay, and social media sites. The process works as follows:

  1. The user types in a search box, and JavaScript detects the input.
  2. JavaScript waits briefly (debouncing) to avoid sending a request on every keystroke.
  3. An AJAX request is sent to the backend with the search query and any filters.
  4. The backend queries the database and returns JSON results.
  5. JavaScript receives the data and dynamically updates the page without a full reload.
User Input > Debounce (300ms) > AJAX Request > Backend Query > JSON Response > DOM Update

For example, when a user types "lap", the browser sends GET /api/products/search?q=lap&category=1 to the server. The server queries the database using a LIKE clause, formats the matching products as JSON, and returns them. JavaScript then renders the results as product cards on the page.


By completing this lab, you will:

  • Understand AJAX and how it enables asynchronous web interactions.
  • Create REST API endpoints that return JSON data.
  • Implement backend search logic using SQL LIKE queries and JOINs.
  • Handle JavaScript events (input, change) for real-time interactions.
  • Implement debouncing to optimize performance and reduce server load.
  • Dynamically update the DOM based on server responses.
  • Filter data by multiple criteria (search term + category).
  • Handle loading states, errors, and empty results gracefully.

Before starting this lab, ensure you have:

  • Completed CRUD operations labs (Create, Read, Update, Delete).
  • Database tables created:
    • products table with columns: id, category_id, name, description, price, stock_quantity.
    • categories table with columns: id, name, description.
    • product_images table with columns: id, product_id, file_path, is_primary.
  • Existing ProductsController and ProductsModel classes.
  • Basic understanding of:
    • JavaScript (variables, functions, arrays, objects).
    • JSON format.
    • Asynchronous programming concepts.

Step 1: Add Search Method to ProductsModel

Section titled “Step 1: Add Search Method to ProductsModel”

Open app/Domain/Models/ProductsModel.php and add this method:

app/Domain/Models/ProductsModel.php
public function searchProducts(string $searchTerm = '', ?int $categoryId = null): array
{
// TODO: Create base SQL query with LEFT JOINs
// - LEFT JOIN categories (c) ON p.category_id = c.id
// - LEFT JOIN product_images (pi) ON p.id = pi.product_id AND pi.is_primary = 1
// (the is_primary condition must be in the ON clause, not WHERE, to keep products without images)
// - Select: p.id, p.name, p.description, p.price, p.stock_quantity,
// c.name AS category_name, c.id AS category_id, pi.file_path AS image_path
// - Start with WHERE 1=1 to make adding conditions easier
// TODO: Initialize empty params array
// TODO: If searchTerm is not empty:
// - Add condition: (p.name LIKE CONCAT('%', :search, '%') OR p.description LIKE CONCAT('%', :search, '%'))
// - Add to params: 'search' => $searchTerm
// TODO: If categoryId is provided and > 0:
// - Add condition: p.category_id = :category_id
// - Add to params: 'category_id' => $categoryId
// TODO: Add GROUP BY p.id and ORDER BY p.name ASC
// TODO: Return results using $this->selectAll($sql, $params)
}

The method accepts a search term and an optional category ID. It searches both the product name and description using SQL LIKE with CONCAT('%', :search, '%') for partial matching (so "lap" matches "laptop"). The WHERE 1=1 trick makes it easy to append AND conditions dynamically. Use LEFT JOIN to include products that may not have a category or image assigned.


Step 2: Add API Search Endpoint to ProductsController

Section titled “Step 2: Add API Search Endpoint to ProductsController”

Open app/Controllers/ProductsController.php and add this method:

app/Controllers/ProductsController.php
public function search(Request $request, Response $response, array $args): Response
{
// TODO: Extract query parameters using $request->getQueryParams()
// - Get 'q' parameter, trim it, default to empty string if not set
// - Get 'category' parameter, convert to int if set, otherwise null
// TODO: Validate search term length
// - If longer than 100 characters, truncate it using substr()
// TODO: Call $this->productsModel->searchProducts() with search term and category ID
// TODO: Create response data array with these keys:
// - 'success' => true
// - 'count' => count of products
// - 'query' => the search term
// - 'category_id' => the category ID
// - 'products' => the products array
// TODO: Convert response data to JSON and write to response body
// @see: https://www.slimframework.com/docs/v4/objects/response.html#returning-json
// - Use json_encode()
// - Use $response->getBody()->write()
// TODO: Return response with proper headers
// - Set Content-Type: application/json
// - Set status code 200
}

This endpoint accepts GET requests with query parameters q (search term) and category (category ID), validates the input, and returns a JSON response with the matching products.


Open app/Routes/web-routes.php and add this route:

app/Routes/web-routes.php
$app->get('/api/products/search', [ProductsController::class, 'search'])
->setName('api.products.search');

To verify, visit http://localhost/[your-subdirectory]/api/products/search?q=laptop in the browser. You should see a JSON response.


Step 4: Get Categories for Dropdown Filter

Section titled “Step 4: Get Categories for Dropdown Filter”

Add this method to app/Domain/Models/ProductsModel.php:

app/Domain/Models/ProductsModel.php
public function getAllCategories(): array
{
// TODO: Select id and name from categories table
// - Order by name ASC
// - Use $this->selectAll() with SQL query
}

Update your userIndex() method in ProductsController.php:

app/Controllers/ProductsController.php
public function userIndex(Request $request, Response $response, array $args): Response
{
// TODO: Get all products using $this->productsModel->getAllProducts()
// TODO: Get all categories using $this->productsModel->getAllCategories()
// TODO: Render the view 'products/userProductIndexView.php'
// - Pass products, categories, and page_title in the data array
}

Verify that the category dropdown populates when you load the products page.


Step 5: Update Products View with Search UI

Section titled “Step 5: Update Products View with Search UI”

Open app/Views/products/userProductIndexView.php and add this search interface:

app/Views/products/userProductIndexView.php
<?php
$page_title = 'Products';
ViewHelper::loadHeader($page_title);
?>
<!-- Search Container -->
<div class="container my-4">
<div class="row mb-4">
<div class="col-md-12">
<h1>Browse Products</h1>
</div>
</div>
<!-- Search and Filter Section -->
<div class="row mb-4">
<div class="col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i> <!-- Bootstrap Icons -->
</span>
<input
type="text"
class="form-control"
id="searchInput"
placeholder="Search products by name or description..."
aria-label="Search products"
>
</div>
</div>
<div class="col-md-3">
<select class="form-select" id="categoryFilter" aria-label="Filter by category">
<option value="">All Categories</option>
<?php foreach ($categories as $category): ?>
<option value="<?= hs($category['id']) ?>">
<?= hs($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-1">
<!-- Loading Spinner -->
<div id="loadingSpinner" class="spinner-border text-primary" role="status" style="display: none;">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- Search Results Container -->
<div id="searchResults" class="row">
<!-- Results will be dynamically inserted here by JavaScript -->
</div>
<!-- Default Products Display (shown when no search active) -->
<div id="defaultProducts" class="row">
<?php foreach ($products as $product): ?>
<div class="col-md-4 mb-4">
<div class="card h-100">
<img
src="<?= hs($product['image_path'] ?? '/images/placeholder.jpg') ?>"
class="card-img-top"
alt="<?= hs($product['name']) ?>"
style="height: 200px; object-fit: cover;"
>
<div class="card-body">
<h5 class="card-title"><?= hs($product['name']) ?></h5>
<p class="card-text"><?= hs(substr($product['description'], 0, 100)) ?>...</p>
<p class="fw-bold text-success">$<?= hs(number_format($product['price'], 2)) ?></p>
<span class="badge bg-secondary"><?= hs($product['category_name'] ?? 'Uncategorized') ?></span>
</div>
<div class="card-footer">
<a href="<?= APP_BASE_URL ?>/products/<?= hs($product['id']) ?>" class="btn btn-primary btn-sm">View Details</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Pass base URL to JavaScript -->
<script>
// Make APP_BASE_URL available to JavaScript
window.APP_BASE_URL = '<?= APP_BASE_URL ?>';
</script>
<!-- Load JavaScript for live search -->
<script src="<?= APP_BASE_URL ?>/public/js/product-search.js"></script>
<?php ViewHelper::loadFooter(); ?>

The view contains a search input (#searchInput), a category dropdown (#categoryFilter), a loading spinner, a results container (#searchResults) for dynamically rendered results, and a default products display. The window.APP_BASE_URL variable is passed to JavaScript so that API requests use the correct path in subdirectory setups.


Step 6: Create Debounce Function (JavaScript)

Section titled “Step 6: Create Debounce Function (JavaScript)”

Create public/js/product-search.js and add this debounce function. Debouncing prevents excessive API calls by waiting until the user stops typing for a specified delay before sending the request:

public/js/product-search.js
function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}

Step 7: Create Fetch Function for API Requests

Section titled “Step 7: Create Fetch Function for API Requests”

Add these functions to public/js/product-search.js. The fetchProducts function builds a query string from the search term and category, sends a GET request to the API endpoint, and returns the products array from the JSON response:

public/js/product-search.js
async function fetchProducts(searchTerm, categoryId) {
try {
const params = new URLSearchParams();
if (searchTerm) params.append('q', searchTerm);
if (categoryId) params.append('category', categoryId);
const response = await fetch(
`${window.APP_BASE_URL}/api/products/search?${params.toString()}`
);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data = await response.json();
return data.products || [];
} catch (error) {
console.error('Fetch error:', error);
showError('Failed to load products. Please try again.');
return [];
}
}
function showError(message) {
const container = document.getElementById('searchResults');
container.textContent = '';
const alert = document.createElement('div');
alert.className = 'col-12';
alert.innerHTML = `<div class="alert alert-danger" role="alert">${message}</div>`;
container.appendChild(alert);
}

The function uses window.APP_BASE_URL to handle subdirectory paths (e.g., /ecommerce/api/products/search).


Add these functions to public/js/product-search.js. When search results arrive, renderProducts hides the default server-rendered products and replaces them with dynamically generated cards. The escapeHtml function prevents XSS attacks by converting special characters to HTML entities:

public/js/product-search.js
function renderProducts(products) {
const resultsContainer = document.getElementById('searchResults');
const defaultContainer = document.getElementById('defaultProducts');
if (defaultContainer) defaultContainer.style.display = 'none';
resultsContainer.textContent = '';
if (products.length === 0) {
const alert = document.createElement('div');
alert.className = 'col-12';
alert.innerHTML = '<div class="alert alert-info" role="alert">No products found matching your search.</div>';
resultsContainer.appendChild(alert);
return;
}
products.forEach(product => {
resultsContainer.appendChild(createProductCard(product));
});
}
function createProductCard(product) {
const col = document.createElement('div');
col.className = 'col-md-4 mb-4';
const description = product.description.length > 100
? product.description.substring(0, 100) + '...'
: product.description;
col.innerHTML = `
<div class="card h-100">
<img
src="${escapeHtml(product.image_path || '/images/placeholder.jpg')}"
class="card-img-top"
alt="${escapeHtml(product.name)}"
style="height: 200px; object-fit: cover;"
>
<div class="card-body">
<h5 class="card-title">${escapeHtml(product.name)}</h5>
<p class="card-text">${escapeHtml(description)}</p>
<p class="fw-bold text-success">$${parseFloat(product.price).toFixed(2)}</p>
<span class="badge bg-secondary">${escapeHtml(product.category_name || 'Uncategorized')}</span>
</div>
<div class="card-footer">
<a href="${escapeHtml(window.APP_BASE_URL)}/products/${escapeHtml(String(product.id))}" class="btn btn-primary btn-sm">View Details</a>
</div>
</div>
`;
return col;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

Add this initialization code to public/js/product-search.js. It sets up a debounced listener on the search input (300ms delay) and an immediate listener on the category dropdown. Pressing Escape clears the search and resets the filters:

public/js/product-search.js
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const categoryFilter = document.getElementById('categoryFilter');
const loadingSpinner = document.getElementById('loadingSpinner');
async function performSearch() {
const searchTerm = searchInput.value.trim();
const categoryId = categoryFilter.value;
loadingSpinner.style.display = 'block';
const products = await fetchProducts(searchTerm, categoryId);
loadingSpinner.style.display = 'none';
renderProducts(products);
}
const debouncedSearch = debounce(performSearch, 300);
searchInput.addEventListener('input', debouncedSearch);
categoryFilter.addEventListener('change', performSearch);
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
searchInput.value = '';
categoryFilter.value = '';
performSearch();
}
});
});

Test these scenarios to ensure everything works:

  1. Type "lap" in the search box and verify products matching “laptop” appear.
  2. Search "game" and select a category to verify combined filtering works.
  3. Type "zzzzz" and verify the “No products found” message appears.
  4. Open the Network tab (F12), type "laptop" quickly, and verify only one request fires after 300ms (debouncing).
  5. Select a category without typing anything to verify immediate filtering.
  6. Type something, press Escape, and verify the search clears and all products appear.
  7. Visit http://localhost/[subdirectory]/api/products/search?q=test directly and verify it returns JSON.

When working with AJAX, the browser’s Network tab is your primary debugging tool:

  1. Open Developer Tools (F12 or right-click and choose Inspect).
  2. Click the Network tab.
  3. Type in the search box to trigger an AJAX request.
  4. Look for requests to /api/products/search in the list.
  5. Click on a request to inspect it.

Check the following:

  • The request URL should be http://localhost/[subdirectory]/api/products/search?q=yourterm&category=1.
  • The status code should be 200 OK. A 404 means the route is not registered or the URL is wrong. A 500 means there is a PHP error (check your Apache error logs).
  • The Response tab should show JSON with success, count, and products keys. If you see HTML instead of JSON, a PHP error page is being returned.
  • The Content-Type header should be application/json.

Several security practices are built into this implementation:

  • SQL injection is prevented by using prepared statements with named parameters. Never concatenate user input directly into SQL like "WHERE name LIKE '%$searchTerm%'". Instead, use CONCAT('%', :search, '%') in the SQL and bind the parameter safely.
  • XSS is prevented on both sides: hs() escapes output in PHP views, and escapeHtml() escapes output in JavaScript before inserting it into the DOM.
  • Input validation truncates the search term to 100 characters and validates that the category ID is numeric.