Laravel 複数条件の絞り込み検索機能 実装ガイド|whenメソッド・動的WHERE句・ページネーション連携

基本文法・構文ガイド実装・応用テクニック

Webアプリケーションや管理画面の開発において、ユーザーが複数の入力条件(キーワード、カテゴリ、ステータス、価格帯、期間など)を指定してデータを絞り込む「複数条件検索(絞り込み検索機能)」は、最も頻繁に実装される重要機能の一つです。

Laravelでは、Eloquent ORMやクエリビルダの強力なメソッド群(特に when() メソッドやローカルスコープ)を活用することで、条件分岐が複雑になりがちな検索ロジックをシンプルかつ保守性高く実装できます。

しかし、実際の開発では以下のような疑問や課題に直面することが少なくありません。

  • 「if文のネストだらけでコントローラーが肥大化(ファットコントローラー化)してしまう」
  • 「複数カラムのOR検索(あいまい検索)を入れると、他のAND条件が崩れてしまう」
  • 「リレーション先(関連テーブル)のデータを使った絞り込みはどう書けばいい?」
  • 「ページネーションで2ページ目に移動すると、検索フォームの入力値が消えてリセットされる」
  • 「SQLインジェクション対策や、LIKE検索の特殊文字(%や_)のエスケープはどうすべき?」

本記事では、Laravelにおける複数条件の絞り込み検索機能の実装手順を基礎から応用・設計のベストプラクティスまで徹底的に解説します。コピー&ペーストしてそのまま現場で使える実践的なサンプルコード付きです。


  1. 1. 【早見表】検索条件に応じたEloquent・クエリビルダ実装メソッド一覧
  2. 2. 動的WHERE句を組み立てる2つの手法(if文 vs whenメソッド)
    1. 手法1:従来の if 文による条件分岐(冗長になりやすい書き方)
    2. 手法2:Laravel推奨の when() メソッドによるメソッドチェーン(推奨)
    3. when() の第3引数(条件が偽だった場合のフォールバック処理)
  3. 3. 実践編:様々な検索条件の実装パターン完全解説
    1. パターン1:複数カラムのあいまい検索(タイトル・本文のOR検索)
      1. 複数キーワード(スペース区切りのAND検索)に対応させる場合
    2. パターン2:チェックボックスによる複数選択(whereIn)
    3. パターン3:数値範囲・価格帯の絞り込み(以上・以下・whereBetween)
    4. パターン4:日付範囲・期間指定の絞り込み(whereDate / Carbon)
    5. パターン5:リレーション先(関連テーブル)による絞り込み(whereHas)
  4. 4. フル実装チュートリアル:複数条件検索機能の構築
    1. Step 1:ルーティングの定義
    2. Step 2:FormRequest でバリデーションとサニタイズを実装
    3. Step 3:コントローラーの実装
    4. Step 4:Bladeテンプレートで検索フォームと入力値保持を実装
    5. Step 5:withQueryString() でページネーション時の検索条件維持
  5. 5. 応用編:クエリスコープ・専用クラスへの切り出し(設計のベストプラクティス)
    1. 設計手法1:Modelのローカルスコープ(Local Scope)にカプセル化
    2. 設計手法2:専用の Query Filter クラス(パイプライン設計)
  6. 6. セキュリティ&パフォーマンス最適化(SQLインジェクション対策とインデックス)
    1. 1. LIKE検索の特殊文字(%・_)のエスケープ処理
    2. 2. 複合インデックス(Composite Index)の設計
    3. 3. ソートパラメータのホワイトリスト検証
  7. 7. 実務でよくある落とし穴・トラブルシューティング 5選
  8. 8. まとめ:複数条件検索の実装チェックリスト
    1. 関連記事・あわせて読みたい
  9. 関連記事

1. 【早見表】検索条件に応じたEloquent・クエリビルダ実装メソッド一覧

検索フォームから送信される様々な条件と、それに対応するLaravel(Eloquent/クエリビルダ)の推奨実装メソッドの対応表です。

検索条件のタイプ 入力例(フォーム) Laravelの推奨メソッド / アプローチ SQLイメージ
条件の動的適用 値が存在するときのみ検索 $query->when($value, function($q, $v) {...}) 動的な WHERE 句の追加
キーワード検索(部分一致) "Laravel チュートリアル" $query->where('col', 'LIKE', "%{$v}%") WHERE col LIKE '%keyword%'
複数カラムのあいまい検索 タイトルまたは本文に含む $query->where(function($q) use ($kw) { $q->where(...)->orWhere(...); }) WHERE (title LIKE '%kw%' OR body LIKE '%kw%')
カテゴリ・完全一致 セレクトボックス(ID選択) $query->where('category_id', $categoryId) WHERE category_id = 1
複数選択(チェックボックス) [1, 3, 5] $query->whereIn('category_id', $ids) WHERE category_id IN (1, 3, 5)
価格・数値の範囲 1,000円 〜 5,000円 $query->where('price', '>=', $min)->where('price', '<=', $max)
または whereBetween()
WHERE price >= 1000 AND price <= 5000
日付・期間の絞り込み 2026-01-01 〜 2026-12-31 $query->whereDate('created_at', '>=', $from)->whereDate('created_at', '<=', $to) WHERE DATE(created_at) >= '2026-01-01'
リレーション先絞り込み 「特定のタグを持つ記事」 $query->whereHas('tags', function($q) use ($tagId) { $q->where('id', $tagId); }) WHERE EXISTS (SELECT * FROM tags ...)
並び替え(ソート) sort=price&direction=desc $query->orderBy($column, $direction)(ホワイトリスト検証必須) ORDER BY price DESC
ページネーション連携 2ページ目以降の遷移 $query->paginate(15)->withQueryString() URLパラメータを自動引き継ぎ

2. 動的WHERE句を組み立てる2つの手法(if文 vs whenメソッド)

検索条件が指定されている時だけ WHERE 条件を追加したい場合、Laravelでは2通りの書き方があります。

手法1:従来の if 文による条件分岐(冗長になりやすい書き方)

// コントローラーでの従来の実装例
public function index(Request $request)
{
    $query = Post::query();

    // キーワードが存在する場合
    if ($request->filled('keyword')) {
        $keyword = $request->input('keyword');
        $query->where('title', 'LIKE', "%{$keyword}%");
    }

    // カテゴリIDが存在する場合
    if ($request->filled('category_id')) {
        $query->where('category_id', $request->input('category_id'));
    }

    // ステータスが存在する場合
    if ($request->filled('status')) {
        $query->where('status', $request->input('status'));
    }

    $posts = $query->paginate(15);

    return view('posts.index', compact('posts'));
}

この手法でも動作自体は問題ありませんが、条件が10個、20個と増えてくると、if 文の羅列によって可読性が低下し、変数の一時代入などコードが散らかりやすくなります。

手法2:Laravel推奨の when() メソッドによるメソッドチェーン(推奨)

LaravelのクエリビルダおよびEloquentには、第1引数の条件が真(Truthy)の場合にのみ第2引数のクロージャ(無名関数)を実行する when() メソッドが備わっています。

use App\Models\Post;
use Illuminate\Http\Request;

public function index(Request $request)
{
    $posts = Post::query()
        // キーワード検索(値があるときだけ実行)
        ->when($request->filled('keyword'), function ($query) use ($request) {
            $keyword = $request->input('keyword');
            $query->where('title', 'LIKE', "%{$keyword}%");
        })
        // カテゴリ絞り込み(第2引数のクロージャは第1引数の評価値を受け取れる)
        ->when($request->input('category_id'), function ($query, $categoryId) {
            $query->where('category_id', $categoryId);
        })
        // ステータス絞り込み
        ->when($request->input('status'), function ($query, $status) {
            $query->where('status', $status);
        })
        ->latest()
        ->paginate(15);

    return view('posts.index', compact('posts'));
}

💡 when() メソッドの引数のポイント:
第2引数のクロージャの第2引数($categoryId$status)には、when() の第1引数に渡した値そのものが自動的に注入されます。そのため、use ($request) を毎回書かずにシンプルに記述できます。

when() の第3引数(条件が偽だった場合のフォールバック処理)

when() メソッドには第3引数として「条件が偽(Falsy)だった場合に実行するクロージャ」を指定することも可能です。

// 並び替えの指定があればそのカラム順、なければデフォルトで最新順(created_at DESC)
$query->when(
    $request->input('sort'),
    function ($query, $sort) {
        $query->orderBy($sort, 'desc');
    },
    function ($query) {
        $query->latest(); // sortパラメータが無い場合のデフォルト
    }
);

3. 実践編:様々な検索条件の実装パターン完全解説

実務で頻出する検索条件の実装パターンを一つずつ詳しく解説します。

パターン1:複数カラムのあいまい検索(タイトル・本文のOR検索)

「タイトル または 本文」にキーワードが含まれている記事を検索する場合、単純に orWhere を繋げるとSQLの優先順位(AND / OR の結合順序)が狂う原因になります。必ず**クロージャでOR条件をグループ化(カッコで囲む)**します。

use Illuminate\Database\Eloquent\Builder;

// 単一キーワードでタイトルまたは本文を部分一致検索
$query->when($request->filled('keyword'), function (Builder $query) use ($request) {
    $keyword = $request->input('keyword');

    // SQL: AND (title LIKE '%kw%' OR body LIKE '%kw%') となるようにグループ化
    $query->where(function (Builder $q) use ($keyword) {
        $q->where('title', 'LIKE', "%{$keyword}%")
          ->orWhere('body', 'LIKE', "%{$keyword}%");
    });
});

⚠️ 注意:クロージャで囲まないアンチパターン
$query->where('status', 'published')->where('title', 'LIKE', "%{$kw}%")->orWhere('body', 'LIKE', "%{$kw}%") のように直接 orWhere を呼ぶと、WHERE status = 'published' AND title LIKE '%kw%' OR body LIKE '%kw%' となり、下書き状態(非公開)のデータまでヒットしてしまいます。

複数キーワード(スペース区切りのAND検索)に対応させる場合

Google検索のように「全角・半角スペース区切り」で入力された複数キーワードすべてに一致するレコードを抽出するパターンです。

$query->when($request->filled('keyword'), function (Builder $query) use ($request) {
    // 全角スペースを半角スペースに統一して配列に分割
    $keyword = mb_convert_kana($request->input('keyword'), 's');
    $keywords = preg_split('/[\s]+/', $keyword, -1, PREG_SPLIT_NO_EMPTY);

    foreach ($keywords as $kw) {
        // 特殊文字をエスケープしてSQLインジェクション&誤ヒットを防止
        $escaped = addcslashes($kw, '%_\\');

        $query->where(function (Builder $q) use ($escaped) {
            $q->where('title', 'LIKE', "%{$escaped}%")
              ->orWhere('body', 'LIKE', "%{$escaped}%");
        });
    }
});

パターン2:チェックボックスによる複数選択(whereIn)

カテゴリやタグを複数選択して「いずれかに該当する(IN句)」データを取得するパターンです。

// フォームから category_ids[] = [1, 2, 5] のように配列で送信される場合
$query->when($request->input('category_ids'), function (Builder $query, $categoryIds) {
    // 配列かつ中身がある場合のみ whereIn を適用
    if (is_array($categoryIds) && count($categoryIds) > 0) {
        $query->whereIn('category_id', $categoryIds);
    }
});

パターン3:数値範囲・価格帯の絞り込み(以上・以下・whereBetween)

最小値(min)と最大値(max)のいずれか、または両方が入力された場合の柔軟な実装です。

// 最小価格(〜円以上)
$query->when($request->filled('price_min'), function (Builder $query) use ($request) {
    $query->where('price', '>=', (int) $request->input('price_min'));
});

// 最大価格(〜円以下)
$query->when($request->filled('price_max'), function (Builder $query) use ($request) {
    $query->where('price', '<=', (int) $request->input('price_max'));
});

パターン4:日付範囲・期間指定の絞り込み(whereDate / Carbon)

作成日や公開日などの日時型(DATETIME / TIMESTAMP)カラムに対して、日付単位(Y-m-d)で絞り込む場合は whereDate を使用します。

// 開始日(〜日以降)
$query->when($request->filled('date_from'), function (Builder $query) use ($request) {
    $query->whereDate('created_at', '>=', $request->input('date_from'));
});

// 終了日(〜日まで)
$query->when($request->filled('date_to'), function (Builder $query) use ($request) {
    $query->whereDate('created_at', '<=', $request->input('date_to'));
});

💡 日時カラムでのインデックス最適化のコツ:
whereDate() は内部で SQL 関数(DATE(created_at))を実行するため、レコード数が数百万件規模になるとインデックスが無効化され検索が遅くなる場合があります。大量データでは以下のように時間範囲(00:00:00 〜 23:59:59)で比較するのがベストです。

use Carbon\Carbon;

$query->when($request->filled('date_to'), function (Builder $query) use ($request) {
    $endOfDay = Carbon::parse($request->input('date_to'))->endOfDay();
    $query->where('created_at', '<=', $endOfDay);
});

パターン5:リレーション先(関連テーブル)による絞り込み(whereHas)

「特定のタグ(リレーション)が付けられた記事」や「特定の著者が書いた記事」を絞り込むには whereHas() を使用します。

// タグIDによる絞り込み(Postモデルが hasMany / belongsToMany で tags を持つ場合)
$query->when($request->input('tag_id'), function (Builder $query, $tagId) {
    $query->whereHas('tags', function (Builder $q) use ($tagId) {
        $q->where('tags.id', $tagId);
    });
});

// 著者名による部分一致絞り込み(Userモデルの name カラム)
$query->when($request->filled('author_name'), function (Builder $query) use ($request) {
    $name = $request->input('author_name');
    $query->whereHas('author', function (Builder $q) use ($name) {
        $q->where('name', 'LIKE', "%{$name}%");
    });
});

4. フル実装チュートリアル:複数条件検索機能の構築

実際にゼロから検索機能付きの一覧画面を構築する手順を、5つのステップで解説します。

🛠️ 作成するコンポーネント一覧

  • ルーティング(Route): GET /posts
  • フォームリクエスト(FormRequest): PostSearchRequest(入力バリデーション)
  • コントローラー(Controller): PostController@index(クエリ構築&ページネーション)
  • ビュー(Blade View): resources/views/posts/index.blade.php(検索フォーム&結果一覧)

Step 1:ルーティングの定義

検索処理はページの再読み込みやブックマーク、URL共有が可能なように GET メソッドで定義します。

// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/posts', [PostController::class, 'index'])->name('posts.index');

Step 2:FormRequest でバリデーションとサニタイズを実装

クエリパラメータの不正な値や予期しない型を防ぐため、専用の FormRequest を作成します。

php artisan make:request PostSearchRequest
// app/Http/Requests/PostSearchRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PostSearchRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'keyword'     => ['nullable', 'string', 'max:100'],
            'category_id' => ['nullable', 'integer', 'exists:categories,id'],
            'status'      => ['nullable', 'string', 'in:draft,published,archived'],
            'price_min'   => ['nullable', 'integer', 'min:0'],
            'price_max'   => ['nullable', 'integer', 'gte:price_min'],
            'date_from'   => ['nullable', 'date'],
            'date_to'     => ['nullable', 'date', 'after_or_equal:date_from'],
            'sort'        => ['nullable', 'string', 'in:created_at,price,title'],
            'direction'   => ['nullable', 'string', 'in:asc,desc'],
        ];
    }
}

Step 3:コントローラーの実装

リレーションのEager Loading(with())によるN+1問題対策と、動的WHERE句の組み立てを行います。

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

use App\Http\Requests\PostSearchRequest;
use App\Models\Category;
use App\Models\Post;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;

class PostController extends Controller
{
    public function index(PostSearchRequest $request): View
    {
        // バリデーション済みデータの取得
        $validated = $request->validated();

        $query = Post::query()
            // N+1問題防止:リレーション先を事前ロード
            ->with(['category', 'tags', 'author']);

        // 1. キーワード検索(タイトル・本文・あいまい検索)
        $query->when($request->filled('keyword'), function (Builder $q) use ($request) {
            $keyword = $request->input('keyword');
            $escaped = addcslashes($keyword, '%_\\');
            $q->where(function (Builder $sub) use ($escaped) {
                $sub->where('title', 'LIKE', "%{$escaped}%")
                    ->orWhere('body', 'LIKE', "%{$escaped}%");
            });
        });

        // 2. カテゴリ絞り込み
        $query->when($request->filled('category_id'), function (Builder $q) use ($request) {
            $q->where('category_id', $request->input('category_id'));
        });

        // 3. ステータス絞り込み
        $query->when($request->filled('status'), function (Builder $q) use ($request) {
            $q->where('status', $request->input('status'));
        });

        // 4. 価格範囲
        $query->when($request->filled('price_min'), function (Builder $q) use ($request) {
            $q->where('price', '>=', (int) $request->input('price_min'));
        });
        $query->when($request->filled('price_max'), function (Builder $q) use ($request) {
            $q->where('price', '<=', (int) $request->input('price_max'));
        });

        // 5. 日付範囲
        $query->when($request->filled('date_from'), function (Builder $q) use ($request) {
            $q->whereDate('created_at', '>=', $request->input('date_from'));
        });
        $query->when($request->filled('date_to'), function (Builder $q) use ($request) {
            $q->whereDate('created_at', '<=', $request->input('date_to'));
        });

        // 6. 並び替え(ソート)
        $sort = $request->input('sort', 'created_at');
        $direction = $request->input('direction', 'desc');
        $query->orderBy($sort, $direction);

        // 7. ページネーション + 検索クエリパラメータの自動引き継ぎ
        $posts = $query->paginate(10)->withQueryString();

        // セレクトボックス用のカテゴリ一覧
        $categories = Category::orderBy('name')->get();

        return view('posts.index', compact('posts', 'categories'));
    }
}

Step 4:Bladeテンプレートで検索フォームと入力値保持を実装

検索フォームでは、検索実行後も入力された値が消えないように request('param') を使ってフォーム部品に初期値をセットします。

<!-- resources/views/posts/index.blade.php -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>記事一覧・複数条件絞り込み検索</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-800 p-8">
    <div class="max-w-6xl mx-auto">
        <h1 class="text-2xl font-bold mb-6">記事一覧・絞り込み検索</h1>

        <!-- 検索フォームカード -->
        <form action="{{ route('posts.index') }}" method="GET" class="bg-white p-6 rounded-lg shadow-sm border mb-8">
            <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
                <!-- キーワード -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">キーワード</label>
                    <input type="text" name="keyword" value="{{ request('keyword') }}" 
                           placeholder="タイトルまたは本文で検索" 
                           class="w-full border rounded px-3 py-2 text-sm focus:ring focus:ring-blue-200">
                </div>

                <!-- カテゴリ -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">カテゴリー</label>
                    <select name="category_id" class="w-full border rounded px-3 py-2 text-sm">
                        <option value="">すべてのカテゴリー</option>
                        @foreach($categories as $cat)
                            <option value="{{ $cat->id }}" {{ request('category_id') == $cat->id ? 'selected' : '' }}>
                                {{ $cat->name }}
                            </option>
                        @endforeach
                    </select>
                </div>

                <!-- ステータス -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">公開状態</label>
                    <select name="status" class="w-full border rounded px-3 py-2 text-sm">
                        <option value="">すべて</option>
                        <option value="published" {{ request('status') === 'published' ? 'selected' : '' }}>公開中</option>
                        <option value="draft" {{ request('status') === 'draft' ? 'selected' : '' }}>下書き</option>
                    </select>
                </div>
            </div>

            <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
                <!-- 期間指定 -->
                <div class="flex items-center space-x-2">
                    <div class="flex-1">
                        <label class="block text-sm font-medium text-gray-700 mb-1">作成日(From)</label>
                        <input type="date" name="date_from" value="{{ request('date_from') }}" class="w-full border rounded px-3 py-2 text-sm">
                    </div>
                    <span class="pt-6 text-gray-400">〜</span>
                    <div class="flex-1">
                        <label class="block text-sm font-medium text-gray-700 mb-1">作成日(To)</label>
                        <input type="date" name="date_to" value="{{ request('date_to') }}" class="w-full border rounded px-3 py-2 text-sm">
                    </div>
                </div>

                <!-- ソート順 -->
                <div class="flex items-center space-x-2">
                    <div class="flex-1">
                        <label class="block text-sm font-medium text-gray-700 mb-1">並び替え項目</label>
                        <select name="sort" class="w-full border rounded px-3 py-2 text-sm">
                            <option value="created_at" {{ request('sort', 'created_at') === 'created_at' ? 'selected' : '' }}>作成日時</option>
                            <option value="price" {{ request('sort') === 'price' ? 'selected' : '' }}>価格</option>
                            <option value="title" {{ request('sort') === 'title' ? 'selected' : '' }}>タイトル</option>
                        </select>
                    </div>
                    <div class="w-32">
                        <label class="block text-sm font-medium text-gray-700 mb-1">昇順/降順</label>
                        <select name="direction" class="w-full border rounded px-3 py-2 text-sm">
                            <option value="desc" {{ request('direction', 'desc') === 'desc' ? 'selected' : '' }}>降順 (新しい順)</option>
                            <option value="asc" {{ request('direction') === 'asc' ? 'selected' : '' }}>昇順 (古い順)</option>
                        </select>
                    </div>
                </div>
            </div>

            <!-- ボタンエリア -->
            <div class="flex justify-end space-x-3 border-t pt-4">
                <a href="{{ route('posts.index') }}" class="px-4 py-2 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300">
                    検索条件をクリア
                </a>
                <button type="submit" class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 shadow">
                    この条件で検索
                </button>
            </div>
        </form>

        <!-- 検索結果一覧 -->
        <div class="bg-white rounded-lg shadow-sm border overflow-hidden mb-6">
            <div class="p-4 bg-gray-50 border-b flex justify-between items-center">
                <span class="text-sm text-gray-600">
                    全 <strong class="text-gray-900">{{ $posts->total() }}</strong> 件中 
                    {{ $posts->firstItem() ?? 0 }} 〜 {{ $posts->lastItem() ?? 0 }} 件目を表示
                </span>
            </div>

            <table class="w-full text-left text-sm">
                <thead class="bg-gray-100 text-gray-600 border-b">
                    <tr>
                        <th class="p-3">ID</th>
                        <th class="p-3">タイトル</th>
                        <th class="p-3">カテゴリー</th>
                        <th class="p-3">価格</th>
                        <th class="p-3">ステータス</th>
                        <th class="p-3">作成日時</th>
                    </tr>
                </thead>
                <tbody class="divide-y">
                    @forelse($posts as $post)
                        <tr class="hover:bg-gray-50">
                            <td class="p-3 text-gray-500">{{ $post->id }}</td>
                            <td class="p-3 font-medium text-gray-900">{{ $post->title }}</td>
                            <td class="p-3">
                                <span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
                                    {{ $post->category->name ?? '未分類' }}
                                </span>
                            </td>
                            <td class="p-3 font-mono">¥{{ number_format($post->price) }}</td>
                            <td class="p-3">
                                @if($post->status === 'published')
                                    <span class="text-green-600 font-semibold">公開中</span>
                                @else
                                    <span class="text-gray-400">下書き</span>
                                @endif
                            </td>
                            <td class="p-3 text-gray-500">{{ $post->created_at->format('Y-m-d H:i') }}</td>
                        </tr>
                    @empty
                        <tr>
                            <td colspan="6" class="p-8 text-center text-gray-500">
                                指定された検索条件に一致する記事は見つかりませんでした。
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>

        <!-- ページネーションリンク -->
        <div class="mt-4">
            {{ $posts->links() }}
        </div>
    </div>
</body>
</html>

Step 5:withQueryString() でページネーション時の検索条件維持

検索結果一覧でページネーションを設置した場合、通常の $posts->links() だけでは2ページ目以降に遷移した際に ?page=2 しかURLに含まれず、検索条件がすべて吹き飛んで初期化されてしまいます。

コントローラー側で ->withQueryString() を呼び出すだけで、現在の $_GET パラメータすべてがページリンクに自動付与されます。

// コントローラー側で withQueryString() をチェーンする
$posts = $query->paginate(10)->withQueryString();

生成されるリンク例:/posts?keyword=laravel&category_id=2&page=2


5. 応用編:クエリスコープ・専用クラスへの切り出し(設計のベストプラクティス)

コントローラー内に大量の when() を直接書くと、コントローラーが検索ロジックで埋め尽くされてしまいます。実務でよく使われる3つのリファクタリング設計パターンを紹介します。

設計手法1:Modelのローカルスコープ(Local Scope)にカプセル化

検索ロジックをModel側に scopeSearch() としてまとめる手法です。コントローラーを極限までシンプルに保てます。

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    /**
     * 検索フィルター用ローカルスコープ
     */
    public function scopeSearch(Builder $query, array $params): Builder
    {
        return $query
            // キーワード検索
            ->when(!empty($params['keyword']), function (Builder $q) use ($params) {
                $kw = addcslashes($params['keyword'], '%_\\');
                $q->where(function (Builder $sub) use ($kw) {
                    $sub->where('title', 'LIKE', "%{$kw}%")
                        ->orWhere('body', 'LIKE', "%{$kw}%");
                });
            })
            // カテゴリ絞り込み
            ->when(!empty($params['category_id']), function (Builder $q) use ($params) {
                $q->where('category_id', $params['category_id']);
            })
            // ステータス絞り込み
            ->when(!empty($params['status']), function (Builder $q) use ($params) {
                $q->where('status', $params['status']);
            })
            // 日付範囲
            ->when(!empty($params['date_from']), function (Builder $q) use ($params) {
                $q->whereDate('created_at', '>=', $params['date_from']);
            })
            ->when(!empty($params['date_to']), function (Builder $q) use ($params) {
                $q->whereDate('created_at', '<=', $params['date_to']);
            });
    }
}

このスコープを定義すると、コントローラーは以下のようにたった2行で完結します。

// app/Http/Controllers/PostController.php
public function index(PostSearchRequest $request)
{
    $posts = Post::with(['category', 'tags'])
        ->search($request->validated())
        ->latest()
        ->paginate(15)
        ->withQueryString();

    return view('posts.index', compact('posts'));
}

設計手法2:専用の Query Filter クラス(パイプライン設計)

モデル自体もスリムに保ちたい大規模プロジェクトでは、専用の PostFilter クラスを作成し、クエリ構築を担当させるパターンが推奨されます。

// app/Filters/PostFilter.php
namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

class PostFilter
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        return $query
            ->when($this->request->filled('keyword'), fn($q) => $this->filterKeyword($q))
            ->when($this->request->filled('category_id'), fn($q) => $this->filterCategory($q))
            ->when($this->request->filled('status'), fn($q) => $this->filterStatus($q));
    }

    protected function filterKeyword(Builder $query): void
    {
        $kw = addcslashes($this->request->input('keyword'), '%_\\');
        $query->where(function ($q) use ($kw) {
            $q->where('title', 'LIKE', "%{$kw}%")
              ->orWhere('body', 'LIKE', "%{$kw}%");
        });
    }

    protected function filterCategory(Builder $query): void
    {
        $query->where('category_id', $this->request->input('category_id'));
    }

    protected function filterStatus(Builder $query): void
    {
        $query->where('status', $this->request->input('status'));
    }
}

6. セキュリティ&パフォーマンス最適化(SQLインジェクション対策とインデックス)

検索機能は外部からの入力値を直接SQLクエリに反映させるため、セキュリティとデータベースの負荷に最も注意を払うべき機能です。

1. LIKE検索の特殊文字(%・_)のエスケープ処理

LaravelのEloquent/クエリビルダは、プリペアドステートメント(パラメータバインド)を使用しているため、通常のSQLインジェクション(シングルクォートなどによる構文破壊)は自動的に防がれます。

しかし、LIKE演算子における %(任意の文字列)や _(任意の1文字)はプレースホルダーではなくワイルドカード文字として解釈されるため、ユーザーが「%」を入力すると全件ヒットしてしまい、サーバーに想定外の高負荷を与える「LIKEインジェクション(DoS脆弱性)」につながります。

// 安全なLIKE検索用ヘルパー関数
function escapeLike(string $value, string $char = '\\'): string
{
    return str_replace(
        [$char, '%', '_'],
        [$char . $char, $char . '%', $char . '_'],
        $value
    );
}

// クエリでの利用
$escaped = escapeLike($request->input('keyword'));
$query->where('title', 'LIKE', "%{$escaped}%");

2. 複合インデックス(Composite Index)の設計

複数のカラム(例:category_idstatuscreated_at)を組み合わせて絞り込む場合、単一カラムごとのインデックスでは十分に高速化されません。よく使われる組み合わせで複合インデックスをマイグレーションに定義します。

// database/migrations/xxxx_add_indexes_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
    // カテゴリとステータスで絞り込んで作成日時順に並べる検索に最適化
    $table->index(['status', 'category_id', 'created_at']);
});

💡 インデックス設計の「最左プレフィックスルール」:
複合インデックス [status, category_id, created_at] は、「等値比較(=)」を行うカラムを左側に配置し、最後に「範囲比較(>=, <=)やソート(ORDER BY)」を行うカラムを配置するのがSQL高速化の鉄則です。

3. ソートパラメータのホワイトリスト検証

ソート対象のカラム名(sort=created_at)や順序(direction=asc)をユーザーのリクエストから受け取る場合、直接 orderBy($request->sort) に渡すと、存在しないカラム名によるSQLエラーや内部構造の漏洩につながります。

// ホワイトリストによる安全なソート処理
$allowedSorts = ['id', 'title', 'price', 'created_at'];
$sort = in_array($request->input('sort'), $allowedSorts, true) ? $request->input('sort') : 'created_at';

$allowedDirections = ['asc', 'desc'];
$direction = in_array(strtolower($request->input('direction')), $allowedDirections, true) ? strtolower($request->input('direction')) : 'desc';

$query->orderBy($sort, $direction);

7. 実務でよくある落とし穴・トラブルシューティング 5選

# 発生するトラブル・現象 原因 解決策
1 非公開データまで検索結果に表示されてしまう orWhere() のグルーピング忘れ where(function($q) { $q->where(...)->orWhere(...); }) でネストする
2 「0」を入力した時に検索条件が無視される when($value)empty($value)0false と判定 filled('col')$value !== null && $value !== '' で明示判定する
3 ページ送り(2ページ目)で絞り込み条件が消える withQueryString() の呼び出し漏れ $query->paginate()->withQueryString() を追加する
4 「%」で検索すると全件ヒット・サーバーが高負荷 LIKEワイルドカードのエスケープ漏れ addcslashes($kw, '%_\\') でエスケープしてからバインドする
5 whereHas() を複数使うと検索が極端に重い EXISTS句のサブクエリ多重実行 適切なテーブル結合(join())への書き換えやインデックス付与を検討

8. まとめ:複数条件検索の実装チェックリスト

Laravelで複数条件の絞り込み検索機能を実装する際の重要チェックポイントをまとめました。

📋 複数条件検索 実装チェックリスト

  • [ ] 動的クエリの構築: when() メソッドを活用し、条件分岐をクリーンに保っているか
  • [ ] OR条件の安全なネスト: 複数カラムのあいまい検索で where(function($q) { ... }) によるグルーピングを行っているか
  • [ ] エスケープ処理: LIKE検索で %_ のワイルドカードインジェクション対策を行っているか
  • [ ] 入力値バリデーション: FormRequest でパラメータの型・範囲・ホワイトリスト検証を行っているか
  • [ ] ページネーションの条件維持: withQueryString() を付与してページ遷移時に入力値が保持されるか
  • [ ] フォームの入力値復元: Bladeテンプレートで request('param') を使って初期値を反映しているか
  • [ ] N+1問題対策: リレーション先の表示に with() によるEager Loadingを行っているか
  • [ ] インデックス設計: 頻繁に絞り込まれるカラムや並び替え対象に適切なインデックスが貼られているか

複数条件検索機能は、適切なメソッド選定と設計パターン(スコープ化やFormRequest)を採用することで、コードの肥大化を防ぎつつ、高速でユーザー体験に優れたWebアプリケーションを構築できます。


関連記事・あわせて読みたい

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント