Product filters are crucial for the user experience in e-commerce. In this article, we show you how to implement a fast, dynamic filtering system with JavaScript and PHP—including AJAX for smooth updates and advanced features for better performance.
<form id="filter-form">
<fieldset>
<legend>Product Filters</legend>
<label>
Category:
<select name="category">
<option value="">All Categories</option>
<option value="shirts">Shirts</option>
<option value="hosen">Pants</option>
<option value="schuhe">Shoes</option>
<option value="accessoires">Accessories</option>
</select>
</label>
<label>
Price:
<select name="price">
<option value="">All Prices</option>
<option value="low">Under €50</option>
<option value="medium">€50–€100</option>
<option value="high">Over €100</option>
</select>
</label>
<label>
Sort:
<select name="sort">
<option value="">Standard</option>
<option value="price_asc">Price Ascending</option>
<option value="price_desc">Price Descending</option>
<option value="name_asc">Name A-Z</option>
</select>
</label>
</fieldset>
</form>
<div id="loading-indicator" style="display: none;">
<p>Products are loading...</p>
</div>
<div id="product-list"></div>
We start with a structured HTML template consisting of a form for filtering and a container for the product list. The use of Fieldset and Legend improves accessibility, while the loading indicator provides visual feedback to the user.
document.getElementById("filter-form").addEventListener("change", function () {
const formData = new FormData(this);
const loadingIndicator = document.getElementById("loading-indicator");
const productList = document.getElementById("product-list");
// Show loading indicator
loadingIndicator.style.display = "block";
productList.style.opacity = "0.5";
fetch("get_products.php", {
method: "POST",
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(html => {
productList.innerHTML = html;
productList.style.opacity = "1";
// Display the number of results
const productCount = productList.querySelectorAll('.product').length;
updateResultCount(productCount);
})
.catch(err => {
console.error("Error loading products:", err);
productList.innerHTML = '<p class="error">Error loading products. Please try again later.</p>';
productList.style.opacity = "1";
})
.finally(() => {
loadingIndicator.style.display = "none";
});
});
function updateResultCount(count) {
let countElement = document.getElementById("result-count");
if (!countElement) {
countElement = document.createElement("p");
countElement.id = "result-count";
document.getElementById("product-list").insertAdjacentElement("beforebegin", countElement);
}
countElement.textContent = `${count} product${count !== 1 ? 's' : ''} found`;
}
This enhanced JavaScript function fetches new product data from the server with every filter change. It provides improved error handling, a loading indicator, and displays the number of found products.
<?php
header('Content-Type: text/html; charset=utf-8');
// Validate and sanitize input data
$category = filter_input(INPUT_POST, 'category', FILTER_SANITIZE_STRING) ?? '';
$price = filter_input(INPUT_POST, 'price', FILTER_SANITIZE_STRING) ?? '';
$sort = filter_input(INPUT_POST, 'sort', FILTER_SANITIZE_STRING) ?? '';
// Example product data (in practice: database query)
$products = [
["id" => 1, "name" => "Blue Shirt", "category" => "shirts", "price" => 29.99, "image" => "shirt1.jpg"],
["id" => 2, "name" => "Black Pants", "category" => "hosen", "price" => 89.50, "image" => "hose1.jpg"],
["id" => 3, "name" => "Designer Shirt", "category" => "shirts", "price" => 120.00, "image" => "shirt2.jpg"],
["id" => 4, "name" => "Sports Shoes", "category" => "schuhe", "price" => 75.00, "image" => "schuh1.jpg"],
["id" => 5, "name" => "Elegant Pants", "category" => "hosen", "price" => 45.00, "image" => "hose2.jpg"],
];
// Filter products by criteria
$filtered = array_filter($products, function ($product) use ($category, $price) {
// Category filter
if ($category && $product['category'] !== $category) {
return false;
}
// Price filter
if ($price === 'low' && $product['price'] >= 50) return false;
if ($price === 'medium' && ($product['price'] < 50 || $product['price'] > 100)) return false;
if ($price === 'high' && $product['price'] <= 100) return false;
return true;
});
// Apply sorting
switch ($sort) {
case 'price_asc':
usort($filtered, fn($a, $b) => $a['price'] <=> $b['price']);
break;
case 'price_desc':
usort($filtered, fn($a, $b) => $b['price'] <=> $a['price']);
break;
case 'name_asc':
usort($filtered, fn($a, $b) => strcasecmp($a['name'], $b['name']));
break;
}
// Generate HTML output
if (empty($filtered)) {
echo '<p class="no-results">No products found that match your criteria.</p>';
} else {
foreach ($filtered as $item) {
$formattedPrice = number_format($item['price'], 2, '.', ','); // Changed , to . for cents in English
echo "<div class='product' data-id='{$item['id']}'>
<img src='images/{$item['image']}' alt='{$item['name']}' loading='lazy'>
<h3>{$item['name']}</h3>
<p class='price'>€{$formattedPrice}</p>
<button class='add-to-cart' data-product-id='{$item['id']}'>Add to Cart</button>
</div>";
}
}
// Example for database query (PDO)
/*
try {
$pdo = new PDO("mysql:host=localhost;dbname=shop", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$sql = "SELECT * FROM products WHERE 1=1";
$params = [];
if ($category) {
$sql .= " AND category = :category";
$params[':category'] = $category;
}
if ($price) {
switch ($price) {
case 'low':
$sql .= " AND price < 50";
break;
case 'medium':
$sql .= " AND price BETWEEN 50 AND 100";
break;
case 'high':
$sql .= " AND price > 100";
break;
}
}
if ($sort) {
switch ($sort) {
case 'price_asc':
$sql .= " ORDER BY price ASC";
break;
case 'price_desc':
$sql .= " ORDER BY price DESC";
break;
case 'name_asc':
$sql .= " ORDER BY name ASC";
break;
}
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log("Database error: " . $e->getMessage());
echo '<p class="error">Error loading products.</p>';
}
*/
?>
The get_products.php file evaluates the POST data, filters and sorts the product list, and returns it as structured HTML. The comments show how to implement a real database connection.
let debounceTimer;
const cache = new Map();
document.getElementById("filter-form").addEventListener("change", function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const formData = new FormData(this);
const cacheKey = new URLSearchParams(formData).toString();
// Check if result is already in cache
if (cache.has(cacheKey)) {
document.getElementById("product-list").innerHTML = cache.get(cacheKey);
// Update result count from cached content (optional but good practice)
const productList = document.getElementById("product-list");
const productCount = productList.querySelectorAll('.product').length;
updateResultCount(productCount);
return;
}
const loadingIndicator = document.getElementById("loading-indicator");
const productList = document.getElementById("product-list");
loadingIndicator.style.display = "block";
productList.style.opacity = "0.5";
fetch("get_products.php", {
method: "POST",
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(html => {
// Save result in cache
cache.set(cacheKey, html);
productList.innerHTML = html;
productList.style.opacity = "1";
const productCount = productList.querySelectorAll('.product').length;
updateResultCount(productCount);
})
.catch(err => {
console.error("Error loading products:", err);
productList.innerHTML = '<p class="error">Error loading products. Please try again later.</p>';
productList.style.opacity = "1";
})
.finally(() => {
loadingIndicator.style.display = "none";
});
}, 300);
});
// Clear cache after 5 minutes
setTimeout(() => {
cache.clear();
}, 5 * 60 * 1000);
With <strong>Debouncing</strong>, we prevent too many rapid requests from being triggered by multiple quick changes. The integrated <strong>Caching</strong> further reduces server requests for identical filter combinations.
#filter-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#filter-form fieldset {
border: none;
padding: 0;
margin: 0;
}
#filter-form legend {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 15px;
color: #333;
}
#filter-form label {
display: inline-block;
margin-right: 20px;
margin-bottom: 10px;
font-weight: 500;
}
#filter-form select {
display: block;
width: 100%;
max-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-top: 5px;
}
#loading-indicator {
text-align: center;
padding: 20px;
font-style: italic;
color: #666;
}
#product-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
transition: opacity 0.3s ease;
}
.product {
border: 1px solid #eee;
border-radius: 8px;
padding: 15px;
background: white;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s ease;
}
.product:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.product img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 10px;
}
.product h3 {
margin: 10px 0;
font-size: 1.1em;
color: #333;
}
.product .price {
font-size: 1.2em;
font-weight: bold;
color: #e74c3c;
margin: 10px 0;
}
.add-to-cart {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease;
}
.add-to-cart:hover {
background: #2980b9;
}
.no-results, .error {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.error {
color: #e74c3c;
background: #fdf2f2;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
#result-count {
margin-bottom: 20px;
font-weight: 500;
color: #666;
}
These CSS styles ensure a professional, responsive presentation of the filtering system. The grid layout automatically adapts to different screen sizes.
A dynamic product filter ensures better user guidance and higher conversion rates. With <strong>JavaScript</strong> and <strong>PHP</strong>, a performant and easily customizable filtering system can be realized for any e-commerce shop. The presented solution offers advanced features like caching, debouncing, and professional error handling, which are essential for production environments. Thanks to the modular structure, you can easily adapt the system to your specific requirements and add further features like search functions or advanced filters.
Not directly, but the logic can be adapted to WooCommerce using custom endpoints and templates. You can use WooCommerce hooks to integrate the system.
Replace the $products array with an SQL query and use PDO, for example, to dynamically load the data. An example can be found in the commented code section.
Yes, simply add more fields to the HTML form and extend the corresponding conditions in the PHP code. The system is modular and easily expandable.
Debouncing ensures that functions are not called too often in quick succession—ideal for rapid UI interactions. It reduces server load and improves performance.
The principle remains the same—State Management and API calls are handled by the JavaScript frameworks. You can use the PHP API unchanged.
Add a search field to the HTML form and extend the PHP logic with a LIKE query or use Elasticsearch for advanced search functions.
Yes, thanks to the responsive CSS Grid layout implementation, the system automatically adapts to various screen sizes.
Implement Pagination, Lazy Loading, and database indexing. Additionally, you can use server-side caching with Redis or Memcached.
Use the advanced WYSIWYG editor in Shopware 6. This editor enables easy embedding of media in descriptions and many additional features.
ab 7.99 €* / Month
Rent PluginOptimize your shop to create a better experience for your customers. This plugin minimizes your shop’s loading time and offers numerous configuration options.
ab 27.49 €* / Month
Rent PluginQuickly and easily create and edit your own template extensions in the administration. Displays existing storefront template paths and contents.
ab 3.99 €* / Month
Rent PluginNote: * All prices are exclusive of VAT
x