サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

Laravelの検索、絞り込み、ソート、ページネーション実務ガイド:保守しやすくアクセシブルな一覧画面

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

Laravelの一覧画面は、検索条件を追加するだけなら難しくありません。

しかし、入力検証、クエリの組み立て、並び順、ページ送り、検索結果の伝達を一つのControllerに詰め込むと、要件変更のたびに修正範囲が広がります。

この記事では、FormRequestとFilterクラスで責務を分け、検索条件を保ったままページを移動できる構成を、Bladeのアクセシビリティとテストまで含めて解説します。

一覧画面を三つの責務に分ける

保守しやすい一覧画面では、入力、検索条件、表示を分けて考えます。

入力の検証
FormRequestが、文字数、日付、選択肢、表示件数の上限を確認します。
クエリの組み立て
Filterクラスが、値のある条件だけをEloquent Builderへ追加します。
結果の表示
Controllerがページ方式を選び、Bladeがフォーム、一覧、結果件数、ページ移動を表示します。

この分割により、検索項目を追加するときに「どこを直すか」が明確になります。

FormRequestの検証とエラー表示を詳しく確認したい場合は、Laravelフォームバリデーションの実務ガイドも参照してください。

サンプルのファイル構成

app/
├─ Http/
│  ├─ Controllers/
│  │  └─ PostIndexController.php
│  ├─ Filters/
│  │  └─ PostFilters.php
│  └─ Requests/
│     └─ PostIndexRequest.php
resources/
└─ views/
   └─ posts/
      └─ index.blade.php

ここで使うモデル名、カラム名、公開状態の値は例です。

実装時には、対象プロジェクトのスキーマと権限設計に合わせて変更してください。

GETで検索条件を送る

検索、絞り込み、並び替えは、データを更新しない操作です。

そのため、検索フォームをGETで送信し、条件をURLのクエリパラメータとして表すと、同じ結果を共有したりブックマークしたりできます。

たとえば、次のURLは「公開中の投稿をLaravelで検索し、新しい順に20件ずつ表示する」という状態を表します。

/posts?q=Laravel&status=published&sort=created_desc&per_page=20

検索フォームにはpageを含めません。

利用者が条件を変更して再検索したときは、現在のページ番号を引き継がず、最初のページから結果を表示するためです。

FormRequestで入力を限定する

クエリパラメータは利用者が変更できるため、Controllerへ渡す前に、受け付けるキーと値の範囲を決めます。

並び順のようにSQLの列や方向へ影響する入力は、自由入力にせず固定候補から選ばせます。

// 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;
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'q' => is_string($this->q) ? trim($this->q) : $this->q,
        ]);
    }

    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'],
        ];
    }

    public function attributes(): array
    {
        return [
            'q'        => 'キーワード',
            'category' => 'カテゴリ',
            'status'   => '公開状態',
            'from'     => '開始日',
            'to'       => '終了日',
            'sort'     => '並び順',
            'per_page' => '表示件数',
        ];
    }
}

prepareForValidation()は、検証前にキーワードの前後の空白を除くために使っています。

Rule::in()は、公開状態と並び順をアプリケーションが想定する値だけに制限します。

per_pageに上限を設けるのは、一度に過大な件数を取得する要求を避けるためです。

Filterクラスで条件を積み上げる

検索条件をControllerへ直接並べると、項目が増えるほど処理の全体像を追いにくくなります。

Filterクラスへ分けると、Controllerは「検証済みの条件を渡す」役割に絞れます。

// 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'])) {
            $keyword = $input['q'];

            $query->where(function (Builder $nested) use ($keyword) {
                $nested->where('title', 'like', "%{$keyword}%")
                    ->orWhere('body', 'like', "%{$keyword}%");
            });
        }

        if (! empty($input['category'])) {
            $categoryIds = array_map('intval', $input['category']);

            $query->whereHas(
                'categories',
                fn (Builder $categoryQuery) =>
                    $categoryQuery->whereIn('categories.id', $categoryIds)
            );
        }

        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']);
        }

        return match ($input['sort'] ?? 'created_desc') {
            'created_asc' => $query
                ->orderBy('created_at')
                ->orderBy('id'),
            'title_asc' => $query
                ->orderBy('title')
                ->orderBy('id'),
            default => $query
                ->orderByDesc('created_at')
                ->orderByDesc('id'),
        };
    }
}

同じ作成日時や同じタイトルの投稿があると、第一の並び順だけでは表示順が確定しません。

最後に主キーを加えて順序を安定させると、ページをまたいだ重複や欠落を調査しやすくなり、カーソルページネーションにも移行しやすくなります。

この例のキーワード検索は、タイトルと本文に対する部分一致検索です。

全文検索エンジンや複数の検索方式を比較したい場合は、Laravelの検索機能設計で選択肢を整理しています。

Controllerでページ方式を選ぶ

Controllerは、関連データの先読み、Filterの適用、ページ方式の選択、表示用データの受け渡しを担当します。

// app/Http/Controllers/PostIndexController.php
namespace App\Http\Controllers;

use App\Http\Filters\PostFilters;
use App\Http\Requests\PostIndexRequest;
use App\Models\Category;
use App\Models\Post;
use Illuminate\Contracts\View\View;

class PostIndexController
{
    public function __invoke(
        PostIndexRequest $request,
        PostFilters $filters
    ): View {
        $input = $request->validated();
        $perPage = $request->integer('per_page', 20);

        $query = Post::query()->with(['author', 'categories']);
        $filters->apply($query, $input);

        $paginator = $query
            ->paginate($perPage)
            ->withQueryString();

        $categories = Category::query()
            ->orderBy('name')
            ->get(['id', 'name']);

        return view('posts.index', [
            'paginator'  => $paginator,
            'categories' => $categories,
            'input'      => $input,
            'resultText' => $this->resultText($paginator),
        ]);
    }

    private function resultText($paginator): string
    {
        if ($paginator->total() === 0) {
            return '条件に一致する投稿は見つかりませんでした。';
        }

        return sprintf(
            '全%d件中、%d件目から%d件目を表示しています。',
            $paginator->total(),
            $paginator->firstItem(),
            $paginator->lastItem()
        );
    }
}

withQueryString()はlinks()の後ではなく、Paginatorインスタンスに対して呼び出します。

これにより、qやstatusなど、現在のクエリパラメータがページリンクへ引き継がれます。

三つのページネーションの違い

方式 表示できる情報 向いている場面 注意点
paginate() 総件数、最終ページ、番号付きリンク 全体量とページ位置を示したい一覧 総件数を求める処理が発生する
simplePaginate() 前後のページへのリンク 総件数や最終ページが不要な一覧 番号付きページリンクは表示しない
cursorPaginate() 前後のカーソルへのリンク 大きなデータや更新頻度の高い一覧 一意に決まる並び順が必要で、番号付きページリンクは表示しない

方式はデータ件数だけで決めません。

「総件数が必要か」「任意のページへ移動する必要があるか」「更新中のデータをどう見せるか」を先に決めます。

cursorPaginate()へ切り替える場合は、並び順に一意な列または一意になる列の組み合わせを指定します。

検索フォームの意味をHTMLで伝える

フォームの見た目だけを区切っても、支援技術には項目同士の関係が伝わりません。

関連する入力をfieldsetで囲み、短いlegendでグループ名を示します。

個々の入力には、見えているlabelを関連付けます。

<a href="#results-heading" class="sr-only focus:not-sr-only">
  検索結果へ移動
</a>

<form method="GET" action="{{ route('posts.index') }}">
  <fieldset>
    <legend>キーワード</legend>

    <label for="q">タイトルまたは本文に含む語句</label>
    <p id="q-help">100文字以内で入力してください。</p>
    <input
      id="q"
      name="q"
      type="search"
      value="{{ old('q', $input['q'] ?? '') }}"
      aria-describedby="q-help @error('q') q-error @enderror"
      @error('q') aria-invalid="true" @enderror
    >

    @error('q')
      <p id="q-error">{{ $message }}</p>
    @enderror
  </fieldset>

  <fieldset>
    <legend>カテゴリ</legend>

    @foreach ($categories as $category)
      <label>
        <input
          type="checkbox"
          name="category[]"
          value="{{ $category->id }}"
          @checked(in_array($category->id, $input['category'] ?? []))
        >
        {{ $category->name }}
      </label>
    @endforeach
  </fieldset>

  <label for="sort">並び順</label>
  <select id="sort" name="sort">
    <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>

  <button type="submit">この条件で検索</button>
  <a href="{{ route('posts.index') }}">すべての検索条件を解除</a>
</form>

fieldsetを使っているため、同じチェックボックス群へrole="group"を重ねる必要はありません。

エラー文は入力と同じ画面に出すだけでなく、aria-invalidとaria-describedbyで入力へ関連付けます。

Webアクセシビリティの基本から点検したい場合は、サイト改善で最初に押さえるWebアクセシビリティの実務ポイントも役立ちます。

結果件数と更新を伝える

検索結果の先頭には、何件見つかり、どの範囲を表示しているかを文章で示します。

画面全体を再読み込みするGET検索では、ページタイトルと検索結果の見出しを明確にし、結果へ移動できるスキップリンクを用意します。

JavaScriptで結果だけを差し替える場合は、更新前から存在する領域へrole="status"を付け、更新後の件数を通知します。

<h2 id="results-heading" tabindex="-1">検索結果</h2>

<p id="result-summary" role="status" aria-atomic="true">
  {{ $resultText }}
</p>

role="status"だけで、画面遷移後のフォーカス位置まで管理できるわけではありません。

部分更新を行う場合は、更新中の状態、完了通知、フォーカスを移す条件を実際の操作手順に沿って確認します。

比較する情報はテーブルで示す

同じ項目で複数の投稿を比較する一覧にはテーブルが適しています。

captionで表の目的を示し、列見出しにはth scope="col"を使います。

<table aria-describedby="result-summary">
  <caption>条件に一致した投稿の一覧</caption>
  <thead>
    <tr>
      <th scope="col">タイトル</th>
      <th scope="col">カテゴリ</th>
      <th scope="col">公開状態</th>
      <th scope="col">作成日</th>
    </tr>
  </thead>
  <tbody>
    @forelse ($paginator as $post)
      <tr>
        <td>
          <a href="{{ route('posts.show', $post) }}">
            {{ $post->title }}
          </a>
        </td>
        <td>{{ $post->categories->pluck('name')->join('、') }}</td>
        <td>
          {{ $post->status === 'published' ? '公開中' : '下書き' }}
        </td>
        <td>{{ $post->created_at->format('Y-m-d') }}</td>
      </tr>
    @empty
      <tr>
        <td colspan="4">
          条件に一致する投稿は見つかりませんでした。
        </td>
      </tr>
    @endforelse
  </tbody>
</table>

公開状態を色付きの丸だけで表すと、色を区別しにくい利用者や読み上げを使う利用者へ状態が伝わりません。

「公開中」「下書き」のようなテキストを残し、色やアイコンは補助として使います。

各項目を順番に読むことが中心なら、ulとliを使ったカード一覧も選べます。

カード内では投稿タイトルを見出しにし、同じ「詳細を見る」の繰り返しではなく、投稿名がわかるリンクテキストを使います。

ページネーションで条件と現在位置を保つ

ControllerでwithQueryString()を呼び出していれば、BladeではPaginatorをそのまま描画できます。

<nav aria-label="検索結果のページ">
  {{ $paginator->onEachSide(1)->links() }}
</nav>

既定のページネーション表示を使う場合も、実際に出力されたHTMLを確認します。

ページネーションを上部と下部の両方へ置くかは、一覧の長さと利用者の移動量で判断します。

同じリンク群を増やすとキーボード操作の回数も増えるため、常に二か所へ置けばよいわけではありません。

0件表示で次の操作を示す

「0件」とだけ表示すると、検索が正常に終わったのか、何を直せばよいのかがわかりません。

0件のメッセージには、対象と次の操作を含めます。

<section aria-labelledby="no-results-heading">
  <h2 id="no-results-heading">条件に一致する投稿はありません</h2>
  <p>
    キーワードを短くする、期間を広げる、カテゴリの選択を減らす、
    のいずれかをお試しください。
  </p>
  <a href="{{ route('posts.index') }}">すべての検索条件を解除</a>
</section>

検索語をメッセージへ表示する場合は、Bladeの{{ }}でエスケープし、意図しないHTMLとして解釈されないようにします。

大きなデータを扱うときの判断

一覧が遅いときに、最初からページ方式だけを変更するのは早計です。

実際のクエリ、インデックス、関連データの取得、検索条件、総件数を必要とする画面要件を確認します。

総ページ数が不要ならsimplePaginate()、番号付きページ移動が不要で一意な並び順を用意できるならcursorPaginate()を候補にします。

追加読み込み式のUIでは、自動的な無限スクロールだけにせず、「さらに表示」ボタンを用意すると、利用者が読み込みのタイミングを制御できます。

読み込み中は対象領域にaria-busy="true"を設定し、完了後に件数の変化を通知します。

入力と出力を安全に扱う

「バリデーションを通った」ことは、「その利用者が閲覧してよいデータだけが表示される」ことを意味しません。

閲覧範囲に制限がある一覧では、認可条件を検索条件とは別にクエリへ適用します。

Featureテストと操作テスト

一覧画面のテストでは、個々の条件だけでなく、条件を組み合わせたときとページ移動後の状態を確認します。

// tests/Feature/PostIndexTest.php
namespace Tests\Feature;

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostIndexTest extends TestCase
{
    use RefreshDatabase;

    public function test_keyword_filters_posts(): void
    {
        Post::factory()->create([
            'title' => 'Laravel 入門',
            'status' => 'published',
        ]);

        Post::factory()->create([
            'title' => 'Vue 実践',
            'status' => 'published',
        ]);

        $response = $this->get('/posts?q=Laravel');

        $response
            ->assertOk()
            ->assertSee('Laravel 入門')
            ->assertDontSee('Vue 実践');
    }

    public function test_pagination_keeps_search_conditions(): void
    {
        Post::factory()->count(30)->create([
            'status' => 'published',
        ]);

        $response = $this->get(
            '/posts?status=published&per_page=10'
        );

        $response
            ->assertOk()
            ->assertSee('status=published')
            ->assertSee('per_page=10');
    }

    public function test_unknown_sort_value_is_rejected(): void
    {
        $response = $this->get('/posts?sort=unknown');

        $response->assertSessionHasErrors('sort');
    }
}

ブラウザで確認する項目

実装前後のチェックリスト

保守しやすい一覧画面のまとめ

Laravelの一覧画面は、FormRequestで入力を限定し、Filterクラスでクエリを組み立て、Controllerでページ方式を選ぶと、役割ごとに変更できます。

表示側では、検索条件をURLとフォームへ残し、結果件数、現在位置、エラー、0件時の次の操作をHTMLで伝えます。

実装後は、レスポンス速度だけでなく、条件を変更してから目的の投稿へ到達するまでの操作を、キーボードと支援技術を含む複数の方法で確認してください。

検索条件の整理から実装、テスト、運用改善までを一続きで設計すると、一覧画面は保守しやすくなります。株式会社greedenは、要件定義からリリース後の改善まで、使いやすいWebシステム開発を一貫して支援しています。

モバイルバージョンを終了