Skip to content

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:

blade
<!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
<?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:

php
use App\Http\Controllers\ProductController;

Route::get('/products', [ProductController::class, 'index'])->name('products.index');

How It Works

Datastar's __debounce modifier delays the search request:

blade
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:

blade
@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:

blade
'_loading' => false  // Local signal (Datastar)
blade
<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:

blade
<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:

blade
<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:

blade
<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:

blade
<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:

php
$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:

blade
<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>