Lab: Live Search with AJAX
Resources
Section titled “Resources”Overview
Section titled “Overview”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:
- User types in search box → JavaScript detects input.
- JavaScript waits briefly (debouncing) to avoid excessive requests.
- AJAX request sent to backend with search query and filters.
- Backend queries database and returns JSON results.
- JavaScript receives data and dynamically updates the page.
- All without page reload!
Learning Objectives
Section titled “Learning Objectives”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.
Prerequisites
Section titled “Prerequisites”Before starting this lab, ensure you have:
- Completed CRUD operations labs (Create, Read, Update, Delete).
- Database tables created:
productstable with columns: id, category_id, name, description, price, stock_quantity.categoriestable with columns: id, name, description.product_imagestable 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.
How Live Search Works
Section titled “How Live Search Works”The Flow
Section titled “The Flow”User Input → Debounce → AJAX Request → Backend Processing → JSON Response → DOM UpdateDetailed Breakdown:
- User types in search box:
"lap" - Debounce waits 300ms: Prevents sending request on every keystroke
- AJAX request fires:
GET http://localhost/[sub-directory-name]/api/products/search?q=lap&category=1 - Backend processes:
- Extract parameters
- Query database:
SELECT * FROM products WHERE name LIKE CONCAT('%', :search, '%') - Format results as JSON
- JSON response returned:
{"success": true,"count": 3,"products": [...]}
- 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.
Lab Steps
Section titled “Lab Steps”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:
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:
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) andcategory(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
Step 3: Register API Route
Section titled “Step 3: Register API Route”Open app/Routes/web-routes.php and add this route:
$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:
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:
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:
<?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):
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:
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).
Step 8: Create DOM Rendering Function
Section titled “Step 8: Create DOM Rendering Function”Add these functions to 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.
Step 9: Wire Up Event Listeners
Section titled “Step 9: Wire Up Event Listeners”Add this initialization code to 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.
Testing Your Implementation
Section titled “Testing Your Implementation”Test these scenarios to ensure everything works:
- Partial Search: Type “lap” → Should show products matching “laptop”
- Combined Filter: Search “game” + select category → Shows products matching both
- Empty Results: Type “zzzzz” → Shows “No products found” message
- Debouncing: Open Network tab (F12), type “laptop” quickly → Only ONE request after 300ms
- Category Filter: Select category → Immediate search (no delay)
- Escape Key: Type something, press Escape → Clears search and shows all products
- Error Handling: Stop server, search → Shows error message without crashing
- API Test: Visit
http://localhost/[subdirectory]/api/products/search?q=test→ Returns JSON
Debugging Tips
Section titled “Debugging Tips”Inspecting AJAX Requests with Network Tab
Section titled “Inspecting AJAX Requests with Network Tab”How to Use Browser Network Tab:
- Open Developer Tools (F12 or Right-click → Inspect)
- Click the Network tab
- Type in the search box to trigger AJAX request
- Look for request to
/api/products/searchin the list - 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
qandcategoryvalues are correct - Response Tab: Should show JSON with
success,count,productsarray - Headers Tab:
Content-Typeshould beapplication/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
Other Debugging Tips
Section titled “Other Debugging Tips”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_URLin 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)inrenderProducts()
Images don’t load:
- Verify image paths in database and files exist in
public/uploads/products/ - Check placeholder image exists:
/images/placeholder.jpg
Common Issues and Solutions
Section titled “Common Issues and Solutions”404 Not Found:
- Missing
window.APP_BASE_URLscript 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/jsonheader 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)
Security Considerations
Section titled “Security Considerations”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 lengthif (strlen($searchTerm) > 100) $searchTerm = substr($searchTerm, 0, 100);
// Validate category IDif ($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();