Search & Filters
This recipe demonstrates building a product listing with real-time search, multiple filters, and pagination that preserves filter state.
What We're Building
A dynamic product catalog with:
- Debounced search-as-you-type
- Multiple filters (category, price range)
- Pagination that preserves filters
- Filter reset functionality
- Result count display
Complete Implementation
Blade Template
Create resources/views/products/index.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Products</title>
@hyper
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">Products</h1>
<div @signals([
'search' => request('search', ''),
'category' => request('category', ''),
'minPrice' => request('minPrice', ''),
'maxPrice' => request('maxPrice', ''),
'page' => request('page', 1),
'products' => $products->items(),
'total' => $products->total(),
'lastPage' => $products->lastPage(),
'_loading' => false
])>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">
Search Products
</label>
<div class="relative">
<input
type="text"
data-bind="search"
data-on:input__debounce.300ms="$_loading = true; $page = 1; @get('/products')"
placeholder="Search by name or description..."
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-attr:disabled="$_loading"
/>
<span data-show="$_loading" class="absolute right-3 top-2.5">
⏳
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Category
</label>
<select
data-bind="category"
data-on:change="$_loading = true; $page = 1; @get('/products')"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-attr:disabled="$_loading">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
<option value="home">Home & Garden</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Price Range
</label>
<div class="flex gap-2">
<input
type="number"
data-bind="minPrice"
data-on:input__debounce.500ms="$_loading = true; $page = 1; @get('/products')"
placeholder="Min"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-attr:disabled="$_loading"
/>
<input
type="number"
data-bind="maxPrice"
data-on:input__debounce.500ms="$_loading = true; $page = 1; @get('/products')"
placeholder="Max"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-attr:disabled="$_loading"
/>
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<p class="text-sm text-gray-600">
<span data-text="$total"></span> products found
</p>
<button
data-on:click="$search = ''; $category = ''; $minPrice = ''; $maxPrice = ''; $page = 1; $_loading = true; @get('/products')"
data-show="$search !== '' || $category !== '' || $minPrice !== '' || $maxPrice !== ''"
class="text-sm text-blue-600 hover:text-blue-800">
Clear all filters
</button>
</div>
</div>
<div id="product-list">
<div data-show="$_loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="text-gray-500 mt-2">Loading products...</p>
</div>
<div data-show="!$_loading && $products.length === 0" class="text-center py-12 bg-gray-50 rounded-lg">
<p class="text-gray-500 text-lg">No products found matching your criteria.</p>
<p class="text-gray-400 text-sm mt-2">Try adjusting your filters</p>
</div>
<div data-show="!$_loading && $products.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<template data-for__key.id="product in $products">
<div class="bg-white rounded-lg shadow hover:shadow-lg transition-shadow p-6">
<img
data-attr:src="product.image"
data-attr:alt="product.name"
class="w-full h-48 object-cover rounded-md mb-4"
/>
<h3 class="text-lg font-semibold text-gray-900 mb-2" data-text="product.name"></h3>
<p class="text-sm text-gray-600 mb-3" data-text="product.category"></p>
<p class="text-sm text-gray-600 mb-3" data-text="product.description"></p>
<p class="text-xl font-bold text-blue-600 mb-4" data-text="'$' + product.price"></p>
<button
data-on:click="@postx('/cart/add/' + product.id)"
class="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 transition-colors">
Add to Cart
</button>
</div>
</template>
</div>
<div data-show="!$_loading && $lastPage > 1" class="flex justify-center items-center gap-2 mt-8">
<button
data-on:click="$_loading = true; $page = $page - 1; @get('/products')"
data-attr:disabled="$page <= 1"
data-class:opacity-50="$page <= 1"
data-class:cursor-not-allowed="$page <= 1"
class="px-4 py-2 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors disabled:hover:bg-white">
← Previous
</button>
<div class="px-4 py-2 bg-gray-100 rounded-md">
<span class="text-gray-700">
Page <strong data-text="$page"></strong> of <strong data-text="$lastPage"></strong>
</span>
</div>
<button
data-on:click="$_loading = true; $page = $page + 1; @get('/products')"
data-attr:disabled="$page >= $lastPage"
data-class:opacity-50="$page >= $lastPage"
data-class:cursor-not-allowed="$page >= $lastPage"
class="px-4 py-2 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors disabled:hover:bg-white">
Next →
</button>
</div>
</div>
</div>
</div>
</body>
</html>Controller
Create app/Http/Controllers/ProductController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$query = Product::query();
if ($request->isHyper()) {
$search = signals('search');
$category = signals('category');
$minPrice = signals('minPrice');
$maxPrice = signals('maxPrice');
$page = (int) signals('page', 1);
} else {
$search = $request->input('search');
$category = $request->input('category');
$minPrice = $request->input('minPrice');
$maxPrice = $request->input('maxPrice');
$page = (int) $request->input('page', 1);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if ($category) {
$query->whereRaw('LOWER(category) = ?', [strtolower($category)]);
}
if ($minPrice) {
$query->where('price', '>=', $minPrice);
}
if ($maxPrice) {
$query->where('price', '<=', $maxPrice);
}
$products = $query->paginate(5, ['*'], 'page', $page);
if ($request->isHyper()) {
return hyper()->signals([
'products' => $products->items(),
'total' => $products->total(),
'page' => $products->currentPage(),
'lastPage' => $products->lastPage(),
'_loading' => false
]);
}
return view('products.index', compact('products'));
}
}Routes
Add to routes/web.php:
use App\Http\Controllers\ProductController;
Route::get('/products', [ProductController::class, 'index'])->name('products.index');How It Works
Debounced Search
Datastar's __debounce modifier delays the search request:
data-on:input__debounce.300ms="$_loading = true; $page = 1; @get('/products?...')"Breakdown:
data-on:input(Datastar) - Listens for input changes__debounce.300ms(Datastar) - Waits 300ms after user stops typing$_loading = true- Shows loading indicator$page = 1- Resets to first page when search changes@get(...)(Datastar) - Fetches filtered results
This prevents a request on every keystroke, reducing server load.
Filter State Management
All filter values are stored in signals:
@signals([
'search' => request('search', ''),
'category' => request('category', ''),
'minPrice' => request('minPrice', ''),
'maxPrice' => request('maxPrice', ''),
'page' => request('page', 1)
])The @signals directive (Hyper) initializes from URL query parameters using Laravel's request() helper, preserving filter state on page refresh.
Loading States
A local signal _loading manages the loading indicator:
'_loading' => false // Local signal (Datastar)<span data-show="$_loading" class="absolute right-3 top-2.5">⏳</span>The data-show attribute (Datastar) toggles visibility based on the loading state.
List Rendering
Hyper's data-for renders the product grid:
<template data-for__key.id="product in $products">
<div class="bg-white rounded-lg shadow p-6">
<img data-attr:src="product.image" />
<h3 data-text="product.name"></h3>
<h3 data-text="product.description"></h3>
<p data-text="'$' + product.price"></p>
</div>
</template>The __key.id modifier tells Hyper to use the id property as the unique key for efficient DOM diffing.
Pagination
Pagination buttons update the page signal and fetch new results:
<button
data-on:click="$_loading = true; $page = $page - 1; @get('/products')"
data-attr:disabled="$page <= 1">
← Previous
</button>The filters are preserved in the URL, so pagination maintains the current search and filter state.
Clear Filters
A single button resets all filters:
<button
data-on:click="$search = ''; $category = ''; $minPrice = ''; $maxPrice = ''; $page = 1; $_loading = true; @get('/products')">
Clear all filters
</button>This shows only when filters are active using data-show (Datastar).
Key Takeaways
1. Debouncing Reduces Server Load: The __debounce modifier (Datastar) prevents excessive requests while users type.
2. URL State Preservation: Initializing signals from request() parameters preserves filter state across page refreshes and enables bookmarking filtered views.
3. Fragment Updates: Using @fragment (Hyper) updates only the product list, keeping filters intact and avoiding full page reloads.
4. Progressive Enhancement: The controller handles both Hyper requests (fragment updates) and regular requests (full page loads) with the same logic.
Enhancements
Sort Options
Add sorting capability:
<select
data-bind="sortBy"
data-on:change="$_loading = true; @get('/products?...' + '&sortBy=' + $sortBy)">
<option value="name">Name</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="newest">Newest First</option>
</select>Controller:
$sortBy = $request->input('sortBy', 'name');
switch ($sortBy) {
case 'price_asc':
$query->orderBy('price', 'asc');
break;
case 'price_desc':
$query->orderBy('price', 'desc');
break;
case 'newest':
$query->orderBy('created_at', 'desc');
break;
default:
$query->orderBy('name', 'asc');
}Active Filter Tags
Show active filters as removable tags:
<div data-show="$search !== ''" class="inline-flex items-center gap-2 bg-blue-100 text-blue-800 px-3 py-1 rounded-full">
<span>Search: <span data-text="$search"></span></span>
<button data-on:click="$search = ''; $_loading = true; @get('...')" class="text-blue-600 hover:text-blue-800">×</button>
</div>
