php elephant sticker
Photo by RealToughCandy.com on Pexels.com

[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, and cursorPaginate 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 with aria-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 numbers
  • simplePaginate() – lighter version if count isn’t needed
  • cursorPaginate() – 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.

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)