[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
FormRequestand a Query Builder wrapper (Filterclass) - Differences and best uses of
paginate,simplePaginate, andcursorPaginateto 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/legendgrouping,aria-livefor notifying result changes, pagination witharia-current,relattributes, 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/legendto 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.
