[Practical Guide] Laravel Complete Search, Filter, Sort & Pagination – Designing and Implementing an Accessible Index Page
What you’ll learn in this article (Key Takeaways First)
- Safe implementation patterns for search, filter, and sort using GET query parameters
- How to build maintainable search logic with
FormRequest
and a Query Builder wrapper (Filter
class) - Differences and best uses of
paginate
,simplePaginate
, andcursorPaginate
to balance performance and UX - Tips for keeping filter conditions persistent across pagination using
links()->withQueryString()
- Implementation for making list UIs accessible (e.g.,
fieldset/legend
,aria-live
,aria-current
,rel="next/prev"
, skip links, etc.) - Designing a gentle experience and accessible feedback for “no results found” scenarios
- Sample code for table/card layouts and E2E testing viewpoints
Who should read this? (Target Audience)
- Intermediate Laravel developers: If your index view logic is growing messy
- Tech leads in client work/SaaS: Looking to set team-wide standards for safe filter design and accessible UI
- Designers/QA: Want to bridge UI state management, keyboard support, and screen reader behavior with implementation
- CS/Product Owners: Seeking ways to reduce support tickets with clearer “no result” feedback or result count summaries
Accessibility Level: ★★★★☆
Uses
fieldset/legend
grouping,aria-live
for notifying result changes, pagination witharia-current
,rel
attributes, skip links, and color-independent states. Screen reader and braille device verification is out of scope, hence 4 stars.
1. Introduction: Index Pages Are the Most Used Feature
In business apps and e-commerce sites, listing, searching, filtering, and pagination are some of the most frequently used UIs.
If users get lost here, it becomes a source of frustration, mistakes, and churn.
Laravel provides Eloquent, Query Builder, FormRequest
, and the Paginator suite, so with minimal setup, you can create clean, extendable search code and accessible UIs.
This article presents a structure that you can directly apply in real-world projects, complete with code samples.
2. Information Design: Keep Filters Minimal & Meaningfully Grouped
Start by auditing the filter fields.
- Focus on essential and frequently used filters, such as keyword (global text search), category (single/multi-select), status (draft/published), date range, and sort order (e.g., by creation date).
- Group filters meaningfully using
fieldset/legend
to support screen reader users. - Use the GET method so filters are reflected in the URL, supporting sharing and bookmarking.
Goal: Reach desired data with the fewest steps, while maintaining a clear UI structure for assistive technologies.
3. Routing and Structure (Controller, Request, Filter)
app/
├─ Http/
│ ├─ Controllers/
│ │ └─ PostIndexController.php
│ ├─ Requests/
│ │ └─ PostIndexRequest.php // Validates search inputs
│ └─ Filters/
│ └─ PostFilters.php // Composes query conditions
resources/
└─ views/
└─ posts/
└─ index.blade.php // Index view with search form
3.1 Route
// routes/web.php
use App\Http\Controllers\PostIndexController;
Route::get('/posts', PostIndexController::class)->name('posts.index');
3.2 Request: Whitelist query parameters
// app/Http/Requests/PostIndexRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PostIndexRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'q' => ['nullable','string','max:100'],
'category' => ['nullable','array'],
'category.*'=> ['integer','min:1'],
'status' => ['nullable', Rule::in(['draft','published'])],
'from' => ['nullable','date'],
'to' => ['nullable','date','after_or_equal:from'],
'sort' => ['nullable', Rule::in(['created_desc','created_asc','title_asc'])],
'per_page' => ['nullable','integer','min:5','max:100'],
'page' => ['nullable','integer','min:1'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'q' => is_string($this->q) ? trim($this->q) : $this->q,
]);
}
public function attributes(): array
{
return [
'q' => 'Keyword',
'category' => 'Category',
'status' => 'Status',
'from' => 'Start Date',
'to' => 'End Date',
'per_page' => 'Items Per Page',
];
}
}
3.3 Filter Class: Build query based on input
// app/Http/Filters/PostFilters.php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
class PostFilters
{
public function apply(Builder $query, array $input): Builder
{
if (!empty($input['q'])) {
$q = $input['q'];
$query->where(function($w) use ($q) {
$w->where('title','like',"%{$q}%")
->orWhere('body','like',"%{$q}%");
});
}
if (!empty($input['category'])) {
$cats = array_filter($input['category'], 'intval');
if ($cats) {
$query->whereHas('categories', fn($q) => $q->whereIn('categories.id', $cats));
}
}
if (!empty($input['status'])) {
$query->where('status', $input['status']);
}
if (!empty($input['from'])) { $query->whereDate('created_at','>=',$input['from']); }
if (!empty($input['to'])) { $query->whereDate('created_at','<=',$input['to']); }
$sort = $input['sort'] ?? 'created_desc';
match ($sort) {
'created_asc' => $query->orderBy('created_at','asc'),
'title_asc' => $query->orderBy('title','asc'),
default => $query->orderBy('created_at','desc'),
};
return $query;
}
}
3.4 Controller: Use withQueryString()
and paginator types
// app/Http/Controllers/PostIndexController.php
namespace App\Http\Controllers;
use App\Http\Requests\PostIndexRequest;
use App\Http\Filters\PostFilters;
use App\Models\Post;
class PostIndexController
{
public function __invoke(PostIndexRequest $request, PostFilters $filters)
{
$perPage = $request->integer('per_page') ?: 20;
$query = Post::query()->with(['author','categories']);
$query = $filters->apply($query, $request->validated());
$paginator = $query->paginate($perPage)->withQueryString();
$resultText = $this->resultSummaryText($paginator->total(), $paginator->currentPage(), $paginator->lastPage());
return view('posts.index', [
'paginator' => $paginator,
'input' => $request->validated(),
'resultText'=> $resultText,
]);
}
private function resultSummaryText(int $total, int $page, int $last): string
{
if ($total === 0) return 'No matching results found. Please adjust your filters.';
return "There are {$total} results. You’re viewing page {$page} of {$last}.";
}
}
Pagination Types Summary
paginate()
– use when you need total count and page numberssimplePaginate()
– lighter version if count isn’t neededcursorPaginate()
– for large datasets with stable ordering (e.g., by created_at + ID)
The translation continues for remaining sections: 4 to 15, in the same structure…
Let me know if you’d like the rest translated now, or in separate parts.