Skip to content

Lab: Live Search with AJAX

In this lab, you’ll implement a live search feature for your e-commerce application that filters products in real-time as users type. This modern user interface pattern eliminates the need for a separate “Search” button, providing instant feedback and improving the shopping experience.

What is Live Search?

Live search (also called “instant search” or “search-as-you-type”) is a dynamic search feature where results update automatically as the user types their query. Instead of requiring users to type their complete search term and click a button, results appear immediately with each keystroke.

Real-World Applications:

  • E-commerce platforms (Amazon, eBay) - Product search.
  • Social media (Twitter, Facebook) - User/content search.
  • Documentation sites - Article search.
  • Admin dashboards - Data filtering.

How It Works:

  1. User types in search box → JavaScript detects input.
  2. JavaScript waits briefly (debouncing) to avoid excessive requests.
  3. AJAX request sent to backend with search query and filters.
  4. Backend queries database and returns JSON results.
  5. JavaScript receives data and dynamically updates the page.
  6. All without page reload!

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.
  • Separate concerns between backend data and frontend presentation.

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.

User Input → Debounce → AJAX Request → Backend Processing → JSON Response → DOM Update

Detailed Breakdown:

  1. User types in search box: "lap"
  2. Debounce waits 300ms: Prevents sending request on every keystroke
  3. AJAX request fires: GET http://localhost/[sub-directory-name]/api/products/search?q=lap&category=1
  4. Backend processes:
    • Extract parameters
    • Query database: SELECT * FROM products WHERE name LIKE CONCAT('%', :search, '%')
    • Format results as JSON
  5. JSON response returned:
    {
    "success": true,
    "count": 3,
    "products": [...]
    }
  6. JavaScript updates DOM: Renders product cards dynamically.

Benefits:

  • Instant feedback - users see results immediately.
  • Better UX - no page reloads or button clicks needed.
  • Reduced clicks - faster path to finding products.
  • Modern feel - matches expectations from major platforms.

Step 1: Add Search Method to ProductsModel

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

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

app/Models/ProductsModel.php
public function searchProducts(string $searchTerm = '', ?int $categoryId = null): array
{
// TODO: Create base SQL query with LEFT JOINs
// - Join products (p) with categories (c) on category_id
// - Join with product_images (pi) where is_primary = 1
// - 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)
}

Requirements:

  • Accept search term (string) and optional category ID (int or null).
  • Return array of products matching search criteria.
  • Search both product name AND description.
  • Filter by category if provided.
  • Include category name and primary image in results.

Hints:

  • Use LEFT JOIN to include products without categories/images.
  • Use prepared statements with named parameters (:search, :category_id).
  • GROUP BY prevents duplicate rows from multiple images.
  • Use SQL CONCAT function to build LIKE pattern: CONCAT('%', :search, '%') enables partial matching (“lap” matches “laptop”).
  • WHERE 1=1 trick makes it easy to append AND conditions.

Verify: Add to controller temporarily: var_dump($this->model->searchProducts('laptop', null)); die();


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->model->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
}

Requirements:

  • Accept GET request with query parameters: q (search term) and category (category ID).
  • Validate and sanitize input.
  • Return JSON response with search results.
  • Include metadata: success status, result count, search parameters.

Hints:

  • Use ?? operator for default values.
  • Use trim() to remove whitespace.
  • Use isset() and ternary operator for optional category.
  • Use $response->withHeader() to set headers.
  • Chain ->withStatus() for status code.

Verify: Visit http://localhost/[subdirectory]/api/products/search?q=laptop in browser - should return JSON


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');

Test: Visit http://localhost/[your-subdirectory]/api/products/search?q=laptop (replace [your-subdirectory] with your folder name, e.g., ecommerce). You should see JSON response.


Step 4: Get Categories for Dropdown Filter

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

Add this method to ProductsModel.php:

app/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->model->getAllProducts()
// TODO: Get all categories using $this->model->getAllCategories()
// TODO: Render the view 'products/userProductIndexView.php'
// - Pass products, categories, and page_title in the data array
}

Verify: Check that 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="/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(); ?>

Key Elements: Search input (#searchInput), category dropdown (#categoryFilter), loading spinner, results container (#searchResults), default products display, and base URL passed to JavaScript for correct API paths in subdirectories.


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 (prevents excessive API calls while typing):

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:

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) {
document.getElementById('searchResults').innerHTML = `
<div class="col-12">
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${message}
</div>
</div>
`;
}

Note: Uses window.APP_BASE_URL to handle subdirectory paths (e.g., /ecommerce/api/products/search).


Add these functions to public/js/product-search.js:

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.innerHTML = '';
if (products.length === 0) {
resultsContainer.innerHTML = `
<div class="col-12">
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i> No products found matching your search.
</div>
</div>
`;
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="/products/${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;
}

Note: escapeHtml() prevents XSS attacks by converting special characters to HTML entities.


Add this initialization code to public/js/product-search.js:

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();
}
});
});

Key Points: Debounced input event (300ms delay), immediate category change event, Escape key to clear search.


Test these scenarios to ensure everything works:

  1. Partial Search: Type “lap” → Should show products matching “laptop”
  2. Combined Filter: Search “game” + select category → Shows products matching both
  3. Empty Results: Type “zzzzz” → Shows “No products found” message
  4. Debouncing: Open Network tab (F12), type “laptop” quickly → Only ONE request after 300ms
  5. Category Filter: Select category → Immediate search (no delay)
  6. Escape Key: Type something, press Escape → Clears search and shows all products
  7. Error Handling: Stop server, search → Shows error message without crashing
  8. API Test: Visit http://localhost/[subdirectory]/api/products/search?q=test → Returns JSON

How to Use Browser Network Tab:

  1. Open Developer Tools (F12 or Right-click → Inspect)
  2. Click the Network tab
  3. Type in the search box to trigger AJAX request
  4. Look for request to /api/products/search in the list
  5. Click on it to see details

What to Check:

  • Request URL: Should be http://localhost/[subdirectory]/api/products/search?q=yourterm&category=1
  • Status Code: Should be 200 OK (not 404, 500, etc.)
  • Request Method: Should be GET
  • Query String Parameters: Verify q and category values are correct
  • Response Tab: Should show JSON with success, count, products array
  • Headers Tab: Content-Type should be application/json

Common Issues Found in Network Tab:

  • 404 Error: Route not registered or wrong URL (missing subdirectory)
  • 500 Error: PHP error in controller/model (check Apache error logs)
  • Empty Response: Controller not returning JSON
  • HTML Instead of JSON: PHP error page being returned

Search doesn’t trigger:

  • Check browser console (F12) for JavaScript errors
  • Verify element IDs: searchInput, categoryFilter, searchResults
  • Ensure JavaScript file is loaded
  • Add console.log('Input fired') in event listener

API returns error:

  • Test API directly: http://localhost/[subdirectory]/api/products/search?q=test
  • Check Apache error logs for PHP errors
  • Verify route is registered in web-routes.php
  • Check window.APP_BASE_URL in console: console.log(window.APP_BASE_URL)

Products don’t display:

  • Check browser console and Network tab for errors
  • Verify JSON response is valid
  • Add console.log(products) in renderProducts()

Images don’t load:

  • Verify image paths in database and files exist in public/uploads/products/
  • Check placeholder image exists: /images/placeholder.jpg

404 Not Found:

  • Missing window.APP_BASE_URL script in view (Step 5)
  • Fetch not using ${window.APP_BASE_URL}/api/products/search
  • Test: console.log(window.APP_BASE_URL) should show /your-subdirectory
  • Manually visit: http://localhost/ecommerce/api/products/search?q=test

JSON Parse Error:

  • Backend returning HTML instead of JSON (likely PHP error)
  • Check for PHP syntax errors
  • Verify Content-Type: application/json header is set

Search Too Slow:

  • Add database indexes: CREATE INDEX idx_product_name ON products(name);
  • Limit results: $sql .= " LIMIT 50";
  • Increase debounce: debounce(performSearch, 500)

SQL Injection Prevention:

  • Use prepared statements with named parameters
  • Never concatenate user input directly into SQL: "WHERE name LIKE '%$searchTerm%'"
  • Use SQL CONCAT function for LIKE patterns: "WHERE name LIKE CONCAT('%', :search, '%')"
  • Then bind the parameter safely: $params = ['search' => $searchTerm]

XSS Prevention:

  • Escape output in PHP: hs() function
  • Escape output in JavaScript: escapeHtml() function
  • Never: col.innerHTML = product.name
  • Always: col.innerHTML = escapeHtml(product.name)

Input Validation:

// Limit length
if (strlen($searchTerm) > 100) $searchTerm = substr($searchTerm, 0, 100);
// Validate category ID
if ($categoryId !== null && !is_numeric($categoryId)) $categoryId = null;

Rate Limiting (Optional):

if (!isset($_SESSION['last_search_time'])) $_SESSION['last_search_time'] = 0;
if ((time() - $_SESSION['last_search_time']) < 1) {
return $response->withStatus(429);
}
$_SESSION['last_search_time'] = time();