【実務で役立つ】Laravel 検索・フィルタ・ソート・ページネーション完全ガイド――アクセシブルな一覧画面の設計と実装
この記事で学べること(先に要点)
- GETクエリパラメータを用いた検索・フィルタ・ソートの安全な実装パターン
FormRequest
と「クエリビルダラッパ(Filterクラス)」で保守しやすい検索処理を構築する方法paginate
/simplePaginate
/cursorPaginate
の違いと使い分け、パフォーマンスとUXの両立links()->withQueryString()
を軸に、ページ送りでフィルタ条件を保持するコツ- 一覧UIをアクセシブルにする実装(
fieldset/legend
、aria-live
、aria-current
、rel="next/prev"
、スキップリンク等) - 「結果が0件」の時のやさしい導線とアクセシビリティ担保
- テーブル/カード型レイアウトでのサンプルコードとE2Eテスト観点
想定読者(だれが得をする?)
- Laravel中級者:一覧画面の要件が増え、コードが煩雑になってきた方
- 受託・SaaS開発のテックリード:安全なフィルタ設計とアクセシブルなUIをチーム標準にしたい方
- デザイナー/QA:検索UIの状態管理・キーボード操作・読み上げ観点を実装とつなぎたい方
- CS/プロダクトオーナー:0件時の体験や結果件数の把握など、問い合わせ削減に直結する改善を探している方
アクセシビリティレベル:★★★★☆
fieldset/legend
によるグルーピング、aria-live
で結果数の変化を通知、ページネーションのaria-current
・rel
・ラベル設計、スキップリンク、色に依存しない状態表現などを具体実装。実機検証(各スクリーンリーダー・点字端末)の網羅は範囲外のため星4。
1. はじめに:一覧画面は「毎日使う最重要機能」
多くの業務アプリやECサイトで、一覧・検索・フィルタ・ページ送りは最も利用頻度の高いUIです。
ここで迷いが生じると、目的の情報にたどり着けず離脱やミス操作の原因になってしまいます。
LaravelはEloquent/Query Builder、FormRequest
、Paginator
群が揃っており、設計を少し整えるだけで読みやすく拡張しやすい検索コードとアクセシブルなUIを実現できます。
本記事では「実務でそのまま流用できる構成」を、サンプル付きで丁寧にご案内しますね♡
2. 情報設計:検索項目は “少数精鋭”、意味のあるグルーピングで
まずは検索項目の棚卸しから。
- キーワード(全体テキスト検索)、カテゴリ(単一/複数選択)、公開状態(下書き/公開)、期間(開始~終了)、並び順(作成日降順など)――といったよく使う最小集合に絞るのが原則。
- UIはグループごとに
fieldset/legend
で意味づけし、スクリーンリーダー利用者にも塊を伝えます。 - GETメソッドで送信することで、URL共有やブックマークにもやさしい設計に。
目的:最短手数で目的の情報に到達/支援技術でも構造が理解しやすいUI。
3. ルーティングと全体構成(Controller・Request・Filterクラス)
app/
├─ Http/
│ ├─ Controllers/
│ │ └─ PostIndexController.php
│ ├─ Requests/
│ │ └─ PostIndexRequest.php // 検索条件のバリデーション
│ └─ Filters/
│ └─ PostFilters.php // クエリビルダを条件に応じて組み立て
resources/
└─ views/
└─ posts/
└─ index.blade.php // 一覧と検索フォーム
3.1 ルート定義
// routes/web.php
use App\Http\Controllers\PostIndexController;
Route::get('/posts', PostIndexController::class)->name('posts.index');
3.2 Request:クエリパラメータをホワイトリスト化
// 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' => 'キーワード',
'category' => 'カテゴリ',
'status' => '公開状態',
'from' => '開始日',
'to' => '終了日',
'per_page' => '表示件数',
];
}
}
3.3 Filterクラス:条件に応じてクエリを積み上げる
// 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:withQueryString()
と Paginator の使い分け
// 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());
// 件数取得は重いことがあるため、必要に応じて simplePaginate/cursorPaginate に切替
$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 '該当する結果は見つかりませんでした。条件を変更してください。';
return "検索結果は {$total} 件です。現在 {$page}/{$last} ページを表示しています。";
}
}
使い分けメモ
paginate()
:総件数を算出し、全ページ数を表示したいとき。simplePaginate()
:総件数不要で次/前のみ出せばよい(重いCOUNT回避)。cursorPaginate()
:巨大データでスケールが必要、かつ安定した並び条件があるとき。
4. アクセシブルな検索フォームの実装(Blade)
{{-- resources/views/posts/index.blade.php の検索フォーム部分 --}}
<a href="#results" class="sr-only focus:not-sr-only underline">検索結果へスキップ</a>
<form method="GET" action="{{ route('posts.index') }}" class="mb-6" novalidate>
<fieldset class="mb-4">
<legend class="font-semibold">キーワード検索</legend>
<label for="q" class="block">キーワード</label>
<input id="q" name="q" type="search" value="{{ $input['q'] ?? '' }}"
class="w-full border rounded px-3 py-2" autocomplete="off">
</fieldset>
<fieldset class="mb-4">
<legend class="font-semibold">カテゴリ</legend>
<div class="flex flex-wrap gap-3" role="group" aria-label="カテゴリ選択">
@foreach ($categories as $cat)
@php $checked = in_array($cat->id, $input['category'] ?? []); @endphp
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="category[]" value="{{ $cat->id }}" @checked($checked)>
<span>{{ $cat->name }}</span>
</label>
@endforeach
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="font-semibold">期間</legend>
<div class="flex gap-4 items-end">
<div>
<label for="from">開始日</label>
<input id="from" name="from" type="date" value="{{ $input['from'] ?? '' }}"
class="border rounded px-3 py-2">
</div>
<div>
<label for="to">終了日</label>
<input id="to" name="to" type="date" value="{{ $input['to'] ?? '' }}"
class="border rounded px-3 py-2">
</div>
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="font-semibold">並び順・表示件数</legend>
<div class="flex gap-4">
<label>
並び順
<select name="sort" class="border rounded px-2 py-2">
<option value="created_desc" @selected(($input['sort'] ?? '')==='created_desc')>新着順</option>
<option value="created_asc" @selected(($input['sort'] ?? '')==='created_asc')>古い順</option>
<option value="title_asc" @selected(($input['sort'] ?? '')==='title_asc')>タイトル昇順</option>
</select>
</label>
<label>
表示件数
<select name="per_page" class="border rounded px-2 py-2">
@foreach([10,20,50,100] as $n)
<option value="{{ $n }}" @selected(($input['per_page'] ?? 20)===$n)>{{ $n }}</option>
@endforeach
</select>
</label>
</div>
</fieldset>
<div class="flex gap-3">
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded">検索</button>
<a class="px-4 py-2 bg-gray-200 rounded" href="{{ route('posts.index') }}">条件をリセット</a>
</div>
</form>
アクセシビリティの要点
- 意味のある塊で
fieldset
とlegend
を使い、読み上げ時の文脈を提供。 - チェックボックス群は
role="group"
とラベルで選択範囲の意味を明示。 - 検索フォームより先に「結果へスキップ」リンクを置き、キーボード操作で重複UIを飛ばせるように。
GET
送信でURL共有でき、CSにもやさしいです♡
5. 結果サマリとライブリージョン:変化を“伝える”
{{-- 検索結果の直前に配置 --}}
<div class="mb-3" aria-live="polite" id="result-summary">
{{ $resultText }}
</div>
- 検索実行やページ移動で件数や現在ページが変わるため、
aria-live="polite"
で読み上げを促します。 - 目視でも強調テキスト(太字や色)で状態変化が分かるように。色盲の方にも配慮し、形・余白でも変化を表現。
6. 一覧本体:テーブル/カードのセマンティクス
6.1 テーブルで“項目ごとに比較”する場合
<table class="w-full border-collapse" aria-describedby="result-summary" id="results">
<caption class="sr-only">投稿一覧</caption>
<thead>
<tr>
<th scope="col" class="text-left p-2">タイトル</th>
<th scope="col" class="text-left p-2">カテゴリ</th>
<th scope="col" class="text-left p-2">公開状態</th>
<th scope="col" class="text-left p-2">作成日</th>
</tr>
</thead>
<tbody>
@forelse ($paginator as $post)
<tr class="border-b">
<td class="p-2">
<a href="{{ route('posts.show',$post) }}" class="underline">{{ $post->title }}</a>
</td>
<td class="p-2">
<ul class="flex flex-wrap gap-1">
@foreach($post->categories as $c)
<li><span class="inline-block px-2 py-1 rounded bg-gray-100">{{ $c->name }}</span></li>
@endforeach
</ul>
</td>
<td class="p-2">
@if($post->status==='published')
<span class="inline-flex items-center gap-1 text-green-700" aria-label="公開中">●</span>
@else
<span class="inline-flex items-center gap-1 text-gray-600" aria-label="下書き">●</span>
@endif
</td>
<td class="p-2">{{ $post->created_at->format('Y-m-d') }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="p-4 text-gray-700">
条件に一致する結果が見つかりませんでした。<br>
キーワードを短くする/カテゴリの選択を減らす/期間を広げる、などをお試しください。
</td>
</tr>
@endforelse
</tbody>
</table>
6.2 カード型で“個別に読む”場合
- カードは
<ul><li>
の一覧構造を明示。 - それぞれのカードには**見出し(
<h3>
)**を置き、一意のリンクを付ける。 - 画像がある場合は代替テキスト(
alt
)を適切に。
7. ページネーション:aria-current
と rel
を忘れずに
{{-- デフォルトの links() でも十分だが、アクセシブルな属性を明示したい場合の例 --}}
@php $p = $paginator; @endphp
@if ($p->hasPages())
<nav class="mt-6" role="navigation" aria-label="ページネーション">
<ul class="inline-flex items-center gap-1">
{{-- 前へ --}}
@if ($p->onFirstPage())
<li aria-disabled="true" class="px-3 py-2 text-gray-400 border rounded">前へ</li>
@else
<li><a class="px-3 py-2 border rounded underline"
href="{{ $p->previousPageUrl() }}"
rel="prev">前へ</a></li>
@endif
{{-- 数字 --}}
@foreach ($elements as $element)
@if (is_string($element))
<li class="px-3 py-2">…</li>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $p->currentPage())
<li aria-current="page" class="px-3 py-2 border rounded bg-blue-600 text-white">{{ $page }}</li>
@else
<li><a class="px-3 py-2 border rounded underline" href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- 次へ --}}
@if ($p->hasMorePages())
<li><a class="px-3 py-2 border rounded underline"
href="{{ $p->nextPageUrl() }}"
rel="next">次へ</a></li>
@else
<li aria-disabled="true" class="px-3 py-2 text-gray-400 border rounded">次へ</li>
@endif
</ul>
</nav>
@endif
ポイント
- 現在ページには
aria-current="page"
。 - 前後リンクには
rel="prev"
/rel="next"
。 withQueryString()
を忘れず、フィルタ状態の維持を徹底。- 画面上部と下部の両方にページネーションを置くと回遊性が高まります♡
8. 大規模データ対応:simplePaginate
/ cursorPaginate
の現場判断
- COUNTが重い:
simplePaginate
(前後リンクのみ)に切替え、体感速度を最優先。 - 無限スクロール系:
cursorPaginate
がスケールしやすい。- 安定した並び(例:
created_at
降順+主キー)を必ず指定。 - ライブ更新時は
aria-busy="true"
とスケルトンUIで読み込みを明示。
- 安定した並び(例:
- アクセシビリティ:無限スクロールは到達可能性が下がりやすいので、明示的な「さらに表示」ボタンを推奨。ボタン押下で追加読み込みし、
aria-live
で件数増加を通知します。
9. 「0件時」の設計:つまずかせない言葉と導線
悪い例:「0件」とだけ表示。
良い例:
- 何が0件か(例:「“Laravel” を含む公開記事は見つかりませんでした」)
- 具体的な改善提案(キーワードを短く、期間を広く、カテゴリを減らす)
- 条件をクリアするリンク(「条件をリセット」)
- 問い合わせ導線(必要に応じて)
これにより、支援技術利用者にも状況と次アクションが正しく伝わります。
10. セキュリティ&健全性:ソート項目のホワイトリスト化
- ソート指定は固定候補に限定(例:
Rule::in([...])
)。 orderBy
へユーザー入力をそのまま渡さない。- 過大な
per_page
は上限(100など)を設定。 - 結果件数テキストはエスケープしてXSSを防止。
11. Featureテスト/E2Eテストの観点
Feature(検索ロジック)
// tests/Feature/PostIndexTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostIndexTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_filters_by_keyword_and_category()
{
$p1 = Post::factory()->create(['title'=>'Laravel 入門','status'=>'published']);
$p2 = Post::factory()->create(['title'=>'Vue 実践','status'=>'published']);
$res = $this->get('/posts?q=Laravel');
$res->assertStatus(200)->assertSee('Laravel 入門')->assertDontSee('Vue 実践');
}
/** @test */
public function it_preserves_query_string_in_pagination_links()
{
Post::factory()->count(30)->create(['status'=>'published']);
$res = $this->get('/posts?status=published&per_page=10');
$res->assertSee('?status=published&per_page=10&page=2');
}
}
E2E(Dusk 等)
- スキップリンクで検索結果へジャンプできるか。
- ページ移動で結果サマリが更新され、
aria-live
が読み上げるか。 - ページネーションで
aria-current="page"
が正しく付与されているか。
12. 完成形サンプル(index.blade.php
抜粋/一覧+ページネーション)
@extends('layouts.app')
@section('title','記事一覧')
@section('content')
<h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">記事一覧</h1>
{{-- 検索フォーム(前掲) --}}
{{-- 結果サマリ(ライブリージョン) --}}
<div class="mb-3" aria-live="polite" id="result-summary">
{{ $resultText }}
</div>
{{-- 一覧(テーブル例) --}}
<div id="results">
@include('posts.partials.table', ['paginator'=>$paginator])
</div>
{{-- ページネーション(上/下の両方に配置も可) --}}
<div class="mt-6">
{{ $paginator->onEachSide(1)->withQueryString()->links() }}
</div>
@endsection
既定の
links()
は十分にアクセシブルですが、デザインや属性制御の要件に応じてカスタムビューに切り出しましょう(前掲のナビゲーション例を参照)。
13. よくある拡張:保存した検索条件/CSVエクスポート
- 検索条件の保存:ユーザー固有のプリセットを
json
カラムで保存し、1クリックで適用。 - CSVエクスポート:現在のフィルタ条件を再利用して抽出。ジョブ化して非同期生成し、完了を通知。
- アクセシビリティ:エクスポート開始・完了を
aria-live
で明示し、ダウンロードリンクは意味のあるリンクテキストに。
14. デザインとアクセシビリティの両立チェックリスト
フォーム
- [ ]
fieldset/legend
で意味的にグループ化 - [ ] すべての入力に
label
とfor
(クリック領域の拡大にも貢献) - [ ]
GET
送信でURL共有・再現性を担保 - [ ] 「結果へスキップ」リンクを用意(キーボードで到達しやすく)
一覧
- [ ] テーブルは
th
/scope
を適切に、カードは<ul><li>
で構造化 - [ ] 状態色は色だけに依存しない(アイコン・テキストも併用)
- [ ] 件数・ページ情報を
aria-live
で読み上げ
ページネーション
- [ ] 現在ページに
aria-current="page"
- [ ] 前後リンクに
rel="prev/next"
- [ ]
withQueryString()
で条件を保持
結果0件
- [ ] 理由の説明・改善提案・リセット導線
セキュリティ
- [ ] ソート/件数はホワイトリスト&上限
- [ ] XSS対策(出力エスケープ)
15. まとめ:だれも迷わない検索体験を Laravel で
本記事では、Request + Filterクラス + Paginator をベースとした検索・フィルタ・ソート・ページネーションの実装手順を、アクセシビリティ観点とともに解説しました。
- 設計:項目は少数精鋭、
fieldset/legend
でグルーピング、GETで共有可能に。 - 実装:
FormRequest
で入力を正規化、Filterクラスでクエリを段積み、withQueryString()
で状態保持。 - UX:
aria-live
で結果変化を伝え、aria-current
・rel
を付与したページネーション、スキップリンクで効率よく移動。 - 運用:巨大データは
simplePaginate
/cursorPaginate
で体感速度を優先、0件時の挫折ポイントを潰す。
これらは、視覚・聴覚・運動・認知の多様なユーザーにとって、迷いの少ない情報探索体験につながります。
ぜひこのサンプルを土台に、プロジェクトの一覧画面を速く・安全に・アクセシブルに磨き上げてくださいね。わたしも応援しています♡