[Complete Practical Guide] UX for Search / Listing / Filtering in Laravel
Eloquent / Scout / Meilisearch, Pagination, Sorting, Faceting, and Accessible Table & Card Views
What you’ll learn in this article (highlights)
- Information architecture for search / listing / filtering, and when to use Eloquent, Query Builder, and Scout
- Whitelisting for sort options, pagination, persistence of query strings
- Integrating Meilisearch / Elasticsearch via Laravel Scout, and how to split responsibilities between full-text search and RDB
- Accessible result lists: table / card structures,
aria-sort,aria-live,aria-busy, and keyboard reachability - Facet (filter) UI, search box (combobox following APG), and navigation when there are “no matching results”
- Exporting, bookmark sharing, SEO / performance, and testing perspectives
Intended readers (who gets value?)
- Beginner to intermediate Laravel engineers who want to safely scale stable list / search functionality
- Tech leads at SaaS / e-commerce / media companies who want to align server load / search quality / UX / accessibility across the board
- Designers / writers / QA who want to standardize screen reader behavior for tables and cards, and interaction patterns for filters / sorting
Accessibility level: ★★★★★
Covers
role="search", comboboxes following APG,aria-sort,aria-live,aria-busy, color-independent badges, filter operations that are fully keyboard-completable, alternative flows when “no matching results,” and consideration forprefers-reduced-motion.
1. Start with design: responsibility split between search / listing / filters
- Search: Use free-form or fuzzy text to collect candidates broadly. With a full-text engine (via Scout), it’s easier to get good relevance.
- Filter: Narrow by fixed conditions (price range, category, stock, enabled/disabled, etc.) using a left-hand sidebar. This is the RDB’s domain.
- Sort: Sort by clear column criteria such as “popularity,” “price,” or “newest.” Use a whitelist to ensure safety.
- Pagination: Ensure a stable order and lightweight navigation.
- View: Table or cards. Respect semantic structure and accompany color cues with other clues (text/icons).
Once you fix these responsibilities, implementation is less likely to drift, and it’s easier to balance performance, UX, and accessibility.
2. Directory structure and routing (so search results are easy to “share”)
app/
├─ Http/
│ ├─ Controllers/SearchController.php
│ └─ Requests/SearchRequest.php
├─ Models/Product.php
└─ Services/
├─ ProductSearchService.php // Scout / full-text search
└─ ProductFilterQuery.php // RDB filters
resources/views/search/
├─ index.blade.php
├─ _filters.blade.php
├─ _table.blade.php
└─ _cards.blade.php
// routes/web.php
Route::get('/products', [SearchController::class, 'index'])->name('products.index'); // List (search/filter/sort/pagination)
Route::get('/products/export', [SearchController::class, 'export'])->name('products.export'); // CSV etc.
If you express state via query strings (for example,
/products?q=laravel&category=books&sort=-created_at&page=2), it’s easier to bookmark and share, and it helps SEO as well.
3. Input validation and whitelisting (foundation for safety and reproducibility)
// app/Http/Requests/SearchRequest.php
class SearchRequest extends FormRequest
{
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'string', 'max:50'],
'min_price' => ['nullable', 'integer', 'min:0', 'max:10000000'],
'max_price' => ['nullable', 'integer', 'min:0', 'max:10000000', 'gte:min_price'],
'in_stock' => ['nullable', 'boolean'],
'sort' => ['nullable', 'in:created_at,-created_at,price,-price,name,-name,popularity,-popularity'],
'view' => ['nullable', 'in:table,card'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:10', 'max:100'],
];
}
public function validatedForQuery(): array
{
$v = $this->validated();
$v['sort'] = $v['sort'] ?? '-created_at';
$v['per_page'] = $v['per_page'] ?? 20;
return $v;
}
}
- Sort options are whitelisted via
in:. Arbitrary columns are dangerous. - Numeric values like price have constrained ranges.
- Page size also has min/max to protect bandwidth.
4. Controller: search → filter → sort → paginate (in that order)
// app/Http/Controllers/SearchController.php
class SearchController extends Controller
{
public function __construct(
private ProductSearchService $searcher,
private ProductFilterQuery $filter
) {}
public function index(SearchRequest $req)
{
$p = $req->validatedForQuery();
// 1) If there is a search term: use Scout / full-text search to obtain a set of candidate IDs
$ids = null;
if (!empty($p['q'])) {
$ids = $this->searcher->ids($p['q']); // Array of top-N IDs
if ($ids === []) {
// When no hits: render empty result + suggestions
return view('search.index', [
'items' => collect(),
'p' => $p,
'facets' => $this->filter->facets(), // Or show overall distribution
'total' => 0,
]);
}
}
// 2) On the RDB side, apply filters / sort / pagination (restricting to IDs if present)
[$items, $total] = $this->filter->run($p, $ids);
return view('search.index', [
'items' => $items,
'p' => $p,
'facets' => $this->filter->facets($p, $ids),
'total' => $total,
]);
}
public function export(SearchRequest $req)
{
$p = $req->validatedForQuery();
[$query] = $this->filter->queryOnly($p, null); // Builder for export
$headers = ['Content-Type' => 'text/csv; charset=UTF-8'];
return response()->streamDownload(function() use($query){
$out = fopen('php://output', 'w');
fputcsv($out, ['ID', 'Name', 'Price', 'Category', 'Stock', 'Created At']);
$query->chunkById(1000, function($rows) use($out){
foreach ($rows as $r) {
fputcsv($out, [$r->id, $r->name, $r->price, $r->category, $r->stock, $r->created_at]);
}
});
fclose($out);
}, 'products.csv', $headers);
}
}
5. RDB filter layer and facet calculation
// app/Services/ProductFilterQuery.php
class ProductFilterQuery
{
public function run(array $p, ?array $ids): array
{
[$q] = $this->queryOnly($p, $ids);
$items = $q->paginate($p['per_page'])->appends($p); // Keep state in query string
return [$items, $items->total()];
}
public function queryOnly(array $p, ?array $ids): array
{
$q = Product::query()->select(['id', 'name', 'price', 'category', 'stock', 'created_at']);
if ($ids !== null) {
$q->whereIn('id', $ids);
// To preserve Scout relevance order, you can use DB-specific FIELD(id, ...) etc.
}
if (!empty($p['category'])) {
$q->where('category', $p['category']);
}
if (isset($p['in_stock'])) {
$p['in_stock'] ? $q->where('stock', '>', 0) : $q->where('stock', 0);
}
if (!empty($p['min_price'])) $q->where('price', '>=', $p['min_price']);
if (!empty($p['max_price'])) $q->where('price', '<=', $p['max_price']);
// Sorting (already whitelisted)
$dir = str_starts_with($p['sort'], '-') ? 'desc' : 'asc';
$col = ltrim($p['sort'], '-');
$q->orderBy($col, $dir)->orderBy('id', 'asc'); // Stable sort
return [$q];
}
public function facets(array $p = [], ?array $ids = null): array
{
// Example: basic category counts
$q = Product::query();
if ($ids) $q->whereIn('id', $ids);
if (!empty($p['min_price'])) $q->where('price', '>=', $p['min_price']);
if (!empty($p['max_price'])) $q->where('price', '<=', $p['max_price']);
if (isset($p['in_stock'])) $p['in_stock'] ? $q->where('stock', '>', 0) : $q->where('stock', 0);
return $q->selectRaw('category, COUNT(*) as cnt')
->groupBy('category')->orderByDesc('cnt')->limit(20)->get()
->map(fn($r) => ['value' => $r->category, 'count' => $r->cnt])->all();
}
}
Key points
- Facets should prioritize lightweight aggregations (or use pre-aggregated tables if needed).
- Sorting is stabilized by adding a second key (
id) to keep pagination consistent. - When using Scout, a two-step flow is convenient: “limit to candidate IDs” → “strict filtering in RDB.”
6. Scout × Meilisearch / Elasticsearch: when to use them?
- Full-text search is useful when you need free-form search, typo tolerance, synonyms, and relevance ordering.
- Relying on
LIKE '%foo%'in RDB hits its limits quickly, both in performance and search quality. - As a principle, write to the RDB, and read (search) via a search engine.
// app/Models/Product.php
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'category' => $this->category,
'tags' => $this->tags->pluck('name'),
];
}
}
// app/Services/ProductSearchService.php
class ProductSearchService
{
public function ids(string $q): array
{
/** @var \Laravel\Scout\Builder $builder */
$builder = Product::search($q)->take(2000); // Upper bound
// With Meilisearch, you can also do $builder->where('stock > 0'), etc.
return $builder->keys()->all(); // Get an array of IDs only
}
}
Operational tips
- Put index updates on a queue.
- Use a separate index for suggestions (popular terms / tags), so they are lightweight.
- Manage synonyms and stopwords as part of a clear operational rule set.
7. View skeleton: search form and filters must be operable by “everyone”
{{-- resources/views/search/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Product List')
@section('content')
<div class="container mx-auto">
{{-- Live announcement of result count --}}
<div id="search-status" role="status" aria-live="polite" class="sr-only">
{{ $total }} results found
</div>
<form role="search" aria-label="Product search" method="get" action="{{ route('products.index') }}" class="mb-4">
<label for="q" class="block font-medium">Keyword</label>
<input id="q" name="q" value="{{ $p['q'] ?? '' }}" class="border rounded px-3 py-2 w-full"
placeholder="Search by product name or tags" autocomplete="off">
<p class="text-sm text-gray-600 mt-1">Examples: Laravel book / template</p>
</form>
<div class="grid grid-cols-12 gap-6">
{{-- Side filters --}}
<aside class="col-span-12 md:col-span-3" aria-labelledby="filter-title">
<h2 id="filter-title" class="text-lg font-semibold mb-2">Filters</h2>
@include('search._filters', ['p' => $p, 'facets' => $facets])
</aside>
{{-- Results --}}
<section class="col-span-12 md:col-span-9" aria-labelledby="result-title" aria-busy="false" id="result">
<div class="flex items-center justify-between mb-2">
<h2 id="result-title" class="text-lg font-semibold">{{ number_format($total) }} results</h2>
<form method="get" action="{{ route('products.index') }}" class="flex items-center gap-2">
@foreach($p as $k => $v) @if(!in_array($k, ['sort','view','page']))
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endif @endforeach
<label for="sort" class="sr-only">Sort</label>
<select id="sort" name="sort" class="border rounded px-2 py-1">
@foreach(['-created_at' => 'Newest', 'created_at' => 'Oldest', '-price' => 'Price: high to low', 'price' => 'Price: low to high', 'name' => 'Name A→Z', '-popularity' => 'Most popular'] as $value => $label)
<option value="{{ $value }}" @selected(($p['sort'] ?? '-created_at') === $value)>{{ $label }}</option>
@endforeach
</select>
<label for="view" class="sr-only">View type</label>
<select id="view" name="view" class="border rounded px-2 py-1">
<option value="table" @selected(($p['view'] ?? 'table') === 'table')>Table</option>
<option value="card" @selected(($p['view'] ?? 'table') === 'card')>Cards</option>
</select>
<button class="px-3 py-1 border rounded">Apply</button>
</form>
</div>
@if(($p['view'] ?? 'table') === 'table')
@include('search._table', ['items' => $items, 'p' => $p])
@else
@include('search._cards', ['items' => $items, 'p' => $p])
@endif
<div class="mt-4">
{{ $items->links() }}
</div>
<p class="mt-2 text-sm">
<a class="underline" href="{{ route('products.export', $p) }}">Download CSV</a>
</p>
</section>
</div>
</div>
@endsection
8. Filter UI (complete as a form, not dependent on color)
{{-- resources/views/search/_filters.blade.php --}}
<form method="get" action="{{ route('products.index') }}" aria-describedby="filter-help">
{{-- Preserve existing parameters --}}
<input type="hidden" name="q" value="{{ $p['q'] ?? '' }}">
<input type="hidden" name="sort" value="{{ $p['sort'] ?? '-created_at' }}">
<input type="hidden" name="view" value="{{ $p['view'] ?? 'table' }}">
<fieldset class="mb-4">
<legend class="font-medium">Category</legend>
<ul class="mt-2 space-y-1">
@foreach($facets as $f)
@php $checked = ($p['category'] ?? null) === $f['value']; @endphp
<li>
<label class="inline-flex items-center">
<input type="radio" name="category" value="{{ $f['value'] }}" @checked($checked)>
<span class="ml-2">{{ $f['value'] }}</span>
<span class="ml-1 text-gray-500">({{ $f['count'] }})</span>
</label>
</li>
@endforeach
<li>
<label class="inline-flex items-center">
<input type="radio" name="category" value="" @checked(empty($p['category']))>
<span class="ml-2">No selection</span>
</label>
</li>
</ul>
</fieldset>
<fieldset class="mb-4">
<legend class="font-medium">Price range</legend>
<div class="flex gap-2 items-center">
<label for="min_price" class="sr-only">Minimum price</label>
<input id="min_price" name="min_price" inputmode="numeric" class="border rounded px-2 py-1 w-24"
value="{{ $p['min_price'] ?? '' }}" placeholder="Min">
<span aria-hidden="true">〜</span>
<label for="max_price" class="sr-only">Maximum price</label>
<input id="max_price" name="max_price" inputmode="numeric" class="border rounded px-2 py-1 w-24"
value="{{ $p['max_price'] ?? '' }}" placeholder="Max">
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="font-medium">Stock</legend>
<label class="inline-flex items-center">
<input type="checkbox" name="in_stock" value="1" @checked(($p['in_stock'] ?? null) == 1)>
<span class="ml-2">Only show items in stock</span>
</label>
</fieldset>
<button class="px-3 py-1 border rounded">Apply filters</button>
<p id="filter-help" class="text-sm text-gray-600 mt-2">
Fully keyboard-operable. Press Enter to apply.
</p>
</form>
- Use
fieldset/legendto clearly group related controls. - Show counts as text, not just color.
- Sorting and view switching can be achieved via form submission, not relying solely on JS.
9. Table view: headers and aria-sort
{{-- resources/views/search/_table.blade.php --}}
<table class="w-full border-collapse">
<caption class="sr-only">Search results</caption>
<thead>
<tr>
@php $sort = $p['sort'] ?? '-created_at'; @endphp
@php
$cols = [
'name' => 'Product name',
'price' => 'Price',
'category' => 'Category',
'stock' => 'Stock',
'created_at' => 'Created at',
];
@endphp
@foreach($cols as $key => $label)
@php
$dir = 'none';
if (ltrim($sort, '-') === $key) $dir = str_starts_with($sort, '-') ? 'descending' : 'ascending';
$next = $dir === 'ascending' ? "-{$key}" : $key;
if ($dir === 'none') $next = $key; // First click = ascending (adjust as needed)
$url = route('products.index', array_merge($p, ['sort' => $next, 'page' => 1]));
@endphp
<th scope="col" class="text-left border-b py-2">
<a href="{{ $url }}" aria-sort="{{ $dir }}" class="underline">
{{ $label }}
<span class="sr-only">
@if($dir === 'ascending')
(ascending, click to sort descending)
@elseif($dir === 'descending')
(descending, click to sort ascending)
@else
(sortable)
@endif
</span>
</a>
</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse($items as $it)
<tr class="border-b">
<td class="py-2">{{ $it->name }}</td>
<td class="py-2">¥{{ number_format($it->price) }}</td>
<td class="py-2">{{ $it->category }}</td>
<td class="py-2">
@if($it->stock > 0)
<span>In stock</span>
@else
<span>Out of stock</span>
@endif
</td>
<td class="py-2">{{ $it->created_at->format('Y-m-d') }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-6 text-center">
No matching results. Try broadening your conditions, or browse from popular categories.
<a class="underline" href="{{ route('products.index', ['sort' => '-popularity']) }}">Show by popularity</a>
</td>
</tr>
@endforelse
</tbody>
</table>
- Add sort links and
aria-sortto column headers. - Don’t rely on visual icons alone; describe the state for screen readers.
- When there are no matches, explicitly offer next steps.
10. Card view: semantics and readable badges
{{-- resources/views/search/_cards.blade.php --}}
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" role="list">
@forelse($items as $it)
<li class="border rounded p-3" aria-label="{{ $it->name }}">
<h3 class="font-semibold text-lg">
<a class="underline" href="{{ route('products.index', ['q' => $it->name]) }}">{{ $it->name }}</a>
</h3>
<p class="mt-1">Price: ¥{{ number_format($it->price) }}</p>
<p class="mt-1">Category: {{ $it->category }}</p>
<p class="mt-1">
@if($it->stock > 0)
<span class="inline-block px-2 py-0.5 rounded bg-green-100 text-green-800">In stock</span>
@else
<span class="inline-block px-2 py-0.5 rounded bg-gray-200 text-gray-800">Out of stock</span>
@endif
</p>
<p class="mt-1 text-sm text-gray-600">Added: {{ $it->created_at->format('Y-m-d') }}</p>
</li>
@empty
<li>No matching results.</li>
@endforelse
</ul>
- Include text in badges instead of relying on color alone.
- Use
role="list"to assist screen readers with grouping.
11. Mini implementation guide for combobox (search suggestions)
Key points
- Follow APG (Authoring Practices) using
role="combobox"andaria-autocomplete="list". - Focus → show
listbox→ move with arrow keys → confirm with Enter. - It must be fully completable without mouse / touch.
(Conceptual simplified example:)
<div class="relative" id="searchbox"
role="combobox" aria-expanded="false" aria-owns="suggestions" aria-haspopup="listbox">
<input id="q" aria-controls="suggestions" aria-autocomplete="list" aria-activedescendant="">
<ul id="suggestions" role="listbox" class="absolute bg-white border w-full hidden"></ul>
</div>
<script type="module">
const box = document.getElementById('searchbox');
const input = document.getElementById('q');
const list = document.getElementById('suggestions');
let active = -1;
let data = [];
input.addEventListener('input', async () => {
const q = input.value.trim();
if (!q) {
list.innerHTML = '';
list.classList.add('hidden');
box.setAttribute('aria-expanded', 'false');
return;
}
// Fetch suggestions via API (/api/suggest?q=...) with small limit
data = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`).then(r => r.json());
list.innerHTML = data.map((it, i) => `<li role="option" id="opt-${i}" class="px-3 py-1">${it}</li>`).join('');
list.classList.remove('hidden');
box.setAttribute('aria-expanded', 'true');
active = -1;
input.setAttribute('aria-activedescendant', '');
});
input.addEventListener('keydown', e => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) return;
const options = [...list.querySelectorAll('[role="option"]')];
if (e.key === 'ArrowDown') {
active = Math.min(active + 1, options.length - 1);
e.preventDefault();
}
if (e.key === 'ArrowUp') {
active = Math.max(active - 1, 0);
e.preventDefault();
}
options.forEach((opt, i) => opt.classList.toggle('bg-blue-100', i === active));
if (active >= 0) input.setAttribute('aria-activedescendant', options[active].id);
if (e.key === 'Enter' && active >= 0) {
input.value = options[active].textContent;
list.classList.add('hidden');
box.setAttribute('aria-expanded', 'false');
}
if (e.key === 'Escape') {
list.classList.add('hidden');
box.setAttribute('aria-expanded', 'false');
}
});
</script>
Note: Suggestions are just assistive. The search must always be doable via normal form submission.
12. Pagination and bookmark sharing
- Use
->appends($p)to preserve conditions across pages. - Infinite scroll is hard to make accessible; provide a “Load more” button alongside it, and use
role="status"to announce progress. - Express search result state in the URL for strong bookmark / share support.
13. Friendly navigation when “no matching results”
- Display a summary of the conditions (e.g., category = books, price ≤ 1,000 yen, in stock only) in text.
- Include one-click dismissible condition chips (×) so they’re easy to clear.
- Alternatives: link to popular sort order, top categories, broader price range, or suggested spellings.
- Provide a contact path (“Didn’t find what you’re looking for? Contact us!”) where appropriate.
14. Performance: indexes, cache, and N+1 issues
- Add compound indexes on columns used in WHERE/ORDER (e.g.,
(category, price, id)). simplePaginate()can be used in some cases to reduce COUNT overhead.- Use
with()to eager load relations and limit selected columns to the minimum. - Use short-lived caches (e.g., 60 seconds) and ETag for lists to save bandwidth.
- Apply rate limits and timeouts for queries to the search engine.
15. Accessibility of CSV / Excel export
- Allow exports with the current conditions from the screen.
- Explain (via
aria-describedby) that “processing may take some time if the number of records is large.” - When progress feedback is needed, use a job plus a progress indicator (
role="progressbar").
16. SEO and metadata (basics for listing pages)
- Set
rel="canonical"appropriately (especially across different sorts / pages). - Titles should be
keyword + separator + site name. - Clearly define rules for when
noindexis required (e.g., spammy queries). - Keep breadcrumbs semantically correct and simple enough for screen readers.
17. Testing: feature, browser, and accessibility
17.1 Feature tests (HTTP/DB)
public function test_filter_and_sort_and_pagination()
{
Product::factory()->count(30)->create(['category' => 'Books']);
Product::factory()->count(10)->create(['category' => 'Goods']);
$res = $this->get('/products?category=Books&sort=-price&per_page=10');
$res->assertOk()->assertSee('results'); // Example: text showing result count
$res->assertSee('Table'); // Example: presence of view-type select
}
17.2 Browser tests (Dusk)
- Clicking table headers actually changes the sort.
- Pagination preserves conditions.
- Changing search terms updates the
aria-liveregion. - Switching between card and table views is completable with keyboard alone.
17.3 a11y smoke (axe / Pa11y)
- Proper association of
role="search"with labels / descriptions. - Correct use of
aria-sort. - No information is conveyed only by color (badges include text).
- Filters are semantically grouped via
fieldset/legend.
18. Common pitfalls and how to avoid them
- Passing arbitrary sort options → fix to an enum / whitelist.
- Excessive
LIKE '%q%'→ offload to Scout / a search engine. - Having only infinite scroll → provide a button and
role="status"progress announcements. - Using color-only badges → always include text as well.
- Silence on “no results” → always show a condition summary and suggestions.
- Filters disappearing on page change → use
->appends()and hidden inputs to preserve state. - Unstable sort order → add a second key (
id) to keep it stable. - Mouse-only suggestions → adhere to APG combobox patterns for full keyboard support.
19. Checklist (for handouts)
Design
- [ ] Free-form search via full-text engine, fixed filters via RDB
- [ ] Sort options whitelisted; add second key for stability
- [ ] Represent state via query string (for sharing / reproducibility)
Accessibility
- [ ]
role="search"with clear labels / descriptions - [ ] Filters grouped via
fieldset/legend - [ ]
aria-sortreflects sort status - [ ]
aria-live/aria-busyannounces counts and updates - [ ] Badges and feedback are not color-only
- [ ] Combobox follows APG; full keyboard completion
Performance
- [ ] Proper indexes and eager loading
- [ ] Short-lived cache / ETag
- [ ] Scout uses queues, limits, and timeouts
UX
- [ ] Suggestions and clearable chips when “no results”
- [ ] Table / card toggle (also achievable via plain form)
- [ ] Export respects current conditions
Tests
- [ ] Pagination preserves conditions
- [ ] Sort order is stable
- [ ] a11y smoke tests for form, table, and suggestions
20. Summary
- By clearly splitting responsibilities between search, filters, sort, and pagination, you can align performance and UX.
- Use Scout (Meilisearch / Elasticsearch) for free-form search, and keep strict conditions in the RDB for a two-layer architecture.
- Build views with
role="search",fieldset/legend,aria-sort,aria-live, andaria-busyfor interfaces that are easy to read and operate. - Express state in query strings to support strong bookmarking and sharing.
- Even when there are “no results,” always show the next step, and avoid conveying meaning solely with color.
- With indexes, caching, ETag, plus testing and accessibility smoke checks, you can grow a resilient search platform that holds up in real-world production.
References
- Laravel official docs
- Accessibility / HTML
- Search design
