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

【実務完全ガイド】Laravelの検索機能設計――LIKE検索、全文検索、Laravel Scout、Meilisearch、Algolia、絞り込み、並び替え、アクセシブルな検索UI

この記事で学べること(要点)

  • Laravelで検索機能を作るときの基本設計
  • LIKE 検索、全文検索、Laravel Scout、Meilisearch、Algolia の使い分け
  • 検索条件、絞り込み、並び替え、ページングを安全に実装する方法
  • 検索インデックス、同期、再構築、キュー、キャッシュの考え方
  • 検索結果が0件のとき、エラー時、読み込み中のアクセシブルなUI設計
  • 検索機能をテストし、運用で壊れにくくするチェックポイント

想定読者

  • Laravel 初〜中級エンジニア:一覧画面に検索を入れたいが、どの方法を選べばよいか迷っている方
  • テックリード:小さな検索から本格的な全文検索まで、段階的な設計方針をチームで整えたい方
  • PM / CS / 運用担当:検索結果が見つからない、並び順が分かりにくい、といった問い合わせを減らしたい方
  • デザイナー / アクセシビリティ担当:検索フォーム、検索結果、0件表示、読み上げ対応を分かりやすく整えたい方

アクセシビリティレベル:★★★★★
検索機能は、利用者が目的の情報へたどり着くための重要な導線です。検索結果件数、現在の条件、0件時の提案、読み込み中の状態、エラー時の案内をテキストで明確にし、role="status"、適切なラベル、見出し構造、キーボード操作、色に依存しない状態表示を前提に設計します。


1. はじめに:検索機能は「入力欄を置くだけ」ではありません

Laravelで検索機能を作るとき、最初は一覧画面にキーワード入力欄を置き、where('title', 'like', ...) を書けば十分に見えるかもしれません。小さな管理画面や件数の少ない一覧であれば、それでも問題なく動きます。しかし、データが増え、検索対象が複数カラムになり、タグやカテゴリ、公開状態、日付、並び替えまで加わると、検索機能は急に複雑になります。

さらに、検索は利用者の期待が高い機能です。入力した語句で結果が出ないと「データがない」と判断されることもありますし、並び順が不自然だと「探しにくい」と感じられます。0件のときに何も案内がなければ、次に何をすればよいか分かりません。つまり検索機能は、バックエンドのクエリ設計だけでなく、UI、文言、パフォーマンス、アクセシビリティ、運用まで含めて考える必要があります。

本記事では、Laravelで検索機能を段階的に育てる方法を解説します。最初はシンプルな LIKE 検索から始め、必要に応じて全文検索、Laravel Scout、Meilisearch、Algoliaへ進む考え方を整理します。


2. 検索方式の選び方:最初から大げさにしないのが大切です

検索機能にはいくつかの選択肢があります。大切なのは、最初から高機能な検索エンジンを入れることではなく、要件に合った方法を選ぶことです。

2.1 小規模なら LIKE 検索で十分な場合があります

たとえば、管理画面でユーザー名やメールアドレスを検索するだけなら、Eloquentの whereLIKE で十分なことが多いです。

$users = User::query()
    ->when($request->filled('q'), function ($query) use ($request) {
        $keyword = $request->string('q')->toString();

        $query->where(function ($q) use ($keyword) {
            $q->where('name', 'like', "%{$keyword}%")
              ->orWhere('email', 'like', "%{$keyword}%");
        });
    })
    ->latest()
    ->paginate(20)
    ->withQueryString();

シンプルで分かりやすく、導入コストも低いです。一方で、データ件数が増えると遅くなりやすく、関連度順や日本語の形態素解析、 typo 許容のような高度な検索には向きません。

2.2 DBの全文検索を使う方法もあります

MySQL、MariaDB、PostgreSQLなどでは、全文検索インデックスを使える場合があります。Laravelのクエリビルダには whereFullText() が用意されており、対応DBで全文検索を扱えます。

$posts = Post::query()
    ->whereFullText(['title', 'body'], $request->input('q'))
    ->paginate(20);

DBの全文検索は、外部サービスを使わずに検索性能を高めたい場合に有効です。ただし、日本語検索、辞書、関連度、重み付け、運用性はDBや設定に依存します。

2.3 Laravel Scout を使うと検索エンジン連携がしやすくなります

Laravel Scoutは、Eloquentモデルに全文検索を追加するための仕組みです。モデルに Searchable trait を追加し、検索対象の配列を定義することで、検索インデックスとモデルを連携しやすくなります。

Scoutは、データベースエンジン、Meilisearch、Algoliaなどと組み合わせて使えます。最初はScoutのdatabase engineで始め、必要になったらMeilisearchやAlgoliaへ移行する、といった段階的な進め方もできます。


3. 検索要件を整理する:実装前に決めておきたいこと

検索機能は、実装前に要件を整理しておくと後戻りが少なくなります。最低限、次を確認します。

  • 何を検索するのか
  • どの項目を検索対象にするのか
  • 部分一致でよいのか、全文検索が必要なのか
  • 並び順は新着順か、関連度順か
  • 絞り込み条件は何か
  • 検索結果が0件のとき、何を表示するか
  • 検索対象に非公開データや権限付きデータが含まれるか
  • 検索結果をキャッシュするか
  • 多言語検索が必要か
  • 入力ミスや表記揺れに対応するか

たとえばECサイトの商品検索と、社内管理画面のユーザー検索では、求められる品質がまったく違います。商品検索では関連度、カテゴリ、価格、在庫、表記揺れが重要になります。一方、管理画面ではメールアドレスやIDで確実に見つかることが重要です。
検索方式は、目的に合わせて選びます。


4. 基本の検索フォーム:GETメソッドで条件をURLに残します

検索フォームは、基本的にGETメソッドを使うのがおすすめです。理由は、検索条件がURLに残り、共有や戻る操作、ページングとの相性が良いからです。

<form method="GET" action="{{ route('posts.index') }}" role="search" class="mb-6">
    <label for="q" class="block font-medium">
        {{ __('キーワード検索') }}
    </label>

    <div class="flex gap-2">
        <input
            id="q"
            name="q"
            type="search"
            value="{{ request('q') }}"
            class="border rounded px-3 py-2 w-full"
            placeholder="タイトルや本文を検索"
        >

        <button type="submit" class="border rounded px-4 py-2">
            検索する
        </button>
    </div>
</form>

ポイントは、role="search"labeltype="search" を使い、検索フォームであることを分かりやすくすることです。プレースホルダーだけで説明するのではなく、必ずラベルを用意します。


5. 検索結果件数を表示する:利用者に現在地を伝えます

検索結果画面では、何件見つかったのかを明示します。これは視覚的にも読み上げでも重要です。

<section aria-labelledby="search-results-title">
    <h1 id="search-results-title" class="text-2xl font-semibold">
        検索結果
    </h1>

    <p role="status" aria-live="polite" class="mt-2 text-sm">
        @if(request('q'))
            「{{ request('q') }}」の検索結果:{{ number_format($posts->total()) }}件
        @else
            {{ number_format($posts->total()) }}件の記事があります。
        @endif
    </p>
</section>

role="status" を使うと、動的に件数が更新されるUIでも状態を伝えやすくなります。通常のページ遷移型検索でも、結果件数がテキストで出ているだけで利用者は安心できます。


6. 0件表示:検索機能の品質は「見つからないとき」に出ます

検索結果が0件のときに、ただ空の一覧を表示するだけでは不親切です。利用者は次に何をすればよいか分かりません。

@if($posts->isEmpty())
    <section aria-labelledby="no-results-title" class="border rounded p-4 mt-6">
        <h2 id="no-results-title" class="text-lg font-semibold">
            検索結果が見つかりませんでした
        </h2>

        <p class="mt-2">
            キーワードを短くするか、別の言葉で検索してみてください。
        </p>

        <ul class="list-disc pl-5 mt-2">
            <li>誤字がないか確認する</li>
            <li>カテゴリや絞り込み条件を外す</li>
            <li>より一般的な言葉で検索する</li>
        </ul>

        <p class="mt-3">
            <a href="{{ route('posts.index') }}" class="underline">
                すべての記事を表示する
            </a>
        </p>
    </section>
@endif

0件表示では、原因を責めるのではなく、次の行動を提案します。
これはアクセシビリティだけでなく、検索体験全体の満足度にも直結します。


7. 絞り込み条件:キーワード検索とフィルタを分けて考えます

検索機能が育つと、キーワードだけでなく、カテゴリ、状態、日付、価格、タグなどの絞り込みが必要になります。実務では、キーワード検索とフィルタ条件を分けて考えると整理しやすいです。

$posts = Post::query()
    ->select(['id', 'title', 'slug', 'status', 'category_id', 'published_at'])
    ->with(['category:id,name'])
    ->when($request->filled('q'), function ($query) use ($request) {
        $keyword = $request->string('q')->toString();

        $query->where(function ($q) use ($keyword) {
            $q->where('title', 'like', "%{$keyword}%")
              ->orWhere('body', 'like', "%{$keyword}%");
        });
    })
    ->when($request->filled('category'), function ($query) use ($request) {
        $query->where('category_id', $request->integer('category'));
    })
    ->when($request->filled('status'), function ($query) use ($request) {
        $query->where('status', $request->input('status'));
    })
    ->latest()
    ->paginate(20)
    ->withQueryString();

検索条件が増えてきたら、FormRequestでバリデーションするのがおすすめです。

class PostSearchRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'q' => ['nullable', 'string', 'max:100'],
            'category' => ['nullable', 'integer', 'exists:categories,id'],
            'status' => ['nullable', 'in:draft,published,archived'],
            'sort' => ['nullable', 'in:latest,oldest,title'],
        ];
    }
}

検索条件も入力値です。安全に扱うため、必ず検証しましょう。


8. 並び替え:許可リスト方式で安全に実装します

並び替えは、SQLインジェクションや不正なクエリの原因になりやすい部分です。リクエストで受け取った値をそのまま orderBy() に渡すのは避けます。

$sort = $request->input('sort', 'latest');

$query = Post::query();

match ($sort) {
    'oldest' => $query->oldest(),
    'title' => $query->orderBy('title'),
    default => $query->latest(),
};

画面側では、現在の並び順を明示します。

<label for="sort">並び替え</label>
<select id="sort" name="sort">
    <option value="latest" @selected(request('sort') === 'latest')>新しい順</option>
    <option value="oldest" @selected(request('sort') === 'oldest')>古い順</option>
    <option value="title" @selected(request('sort') === 'title')>タイトル順</option>
</select>

「並び替えが変わった」ことが分かるように、選択中の条件を結果件数の近くに表示するのも有効です。


9. ページング:検索条件を保持することが大切です

検索結果のページングでは、条件を保持する必要があります。Laravelでは withQueryString() を使うと便利です。

$posts = $query->paginate(20)->withQueryString();

これにより、2ページ目へ移動しても qcategory などの条件が維持されます。
条件が消えると、利用者は「検索結果の続きを見たつもりが、全件一覧に戻っていた」という混乱を感じます。小さなことですが、検索体験ではとても重要です。


10. Laravel Scoutの基本:検索対象をモデル側で定義します

Laravel Scoutを使う場合、モデルに Searchable trait を追加します。

namespace App\Models;

use Laravel\Scout\Searchable;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id' => (string) $this->id,
            'title' => $this->title,
            'body' => $this->body,
            'status' => $this->status,
            'category_id' => $this->category_id,
            'published_at' => optional($this->published_at)?->timestamp,
        ];
    }
}

検索は次のように書けます。

$posts = Post::search($request->input('q'))
    ->query(function ($query) {
        $query->where('status', 'published');
    })
    ->paginate(20);

Scoutを使う利点は、検索インデックスとEloquentモデルを結びつけやすいことです。モデルの作成・更新・削除に合わせて検索インデックスを同期できます。


11. Scout導入時に考えること:検索対象を出しすぎない

toSearchableArray() には、検索に必要な情報だけを入れます。何でも入れると、インデックスが大きくなり、更新も重くなります。さらに、非公開情報や内部メモが検索対象に入ると危険です。

検索対象に含める候補:

  • タイトル
  • 本文
  • 概要
  • カテゴリID
  • タグ名
  • 公開状態
  • 公開日時

含めない方がよいもの:

  • 個人情報
  • 内部メモ
  • 権限が必要な情報
  • 表示に不要な巨大データ
  • 秘密情報やトークン

検索インデックスは、アプリ本体のDBとは別の「見つけるためのデータ」です。
外へ出してよい情報か、検索対象にしてよい情報かを意識して設計しましょう。


12. MeilisearchやAlgoliaが必要になる場面

小さな検索ならDB検索で十分です。しかし、次のような要件が出てきたら、MeilisearchやAlgoliaのような検索エンジンを検討します。

  • 関連度順で自然に並べたい
  • typo許容がほしい
  • 日本語や多言語検索を強化したい
  • 絞り込みやファセット検索を高速にしたい
  • 大量データでも高速に検索したい
  • 検索ログやランキング改善を行いたい

ただし、検索エンジンを導入すると、インフラ、同期、再構築、障害時対応が増えます。導入前に「本当に必要か」を確認し、まずは小さく試すのが安全です。


13. インデックス同期:キューと再構築手順を用意します

検索インデックスは、DBと同期している必要があります。モデルが更新されたのに検索結果が古いままだと、利用者は混乱します。

Scoutではインポートコマンドを使ってインデックスを作成できます。

php artisan scout:import "App\Models\Post"

削除や再構築が必要な場合もあります。

php artisan scout:flush "App\Models\Post"
php artisan scout:import "App\Models\Post"

実務では、次を決めておくと安心です。

  • いつインデックスを更新するか
  • キューで非同期化するか
  • 大量データの再インポート手順
  • 再構築中の検索画面の扱い
  • 検索エンジン障害時のフォールバック

検索は「動いているとき」より「更新がずれたとき」に問題が見えます。運用手順まで含めて設計しましょう。


14. 権限付き検索:検索結果にも認可が必要です

検索結果に非公開データやユーザーごとの権限が関係する場合、注意が必要です。
DB検索ならクエリで where を追加できますが、検索エンジンを使う場合は、インデックスに権限情報を持たせるか、検索後に絞り込む設計が必要になります。

たとえば公開記事だけを検索対象にするなら、インデックス段階で status を持たせます。

public function toSearchableArray(): array
{
    return [
        'id' => (string) $this->id,
        'title' => $this->title,
        'body' => $this->body,
        'status' => $this->status,
    ];
}

検索時:

$posts = Post::search($keyword)
    ->where('status', 'published')
    ->paginate(20);

管理画面のように権限が細かい場合は、検索エンジンだけで完結させず、最終的な認可チェックをアプリ側で行うことも検討します。
「検索で見えてはいけないものが出る」事故は重大です。検索機能にも認可設計が必要です。


15. 検索ログ:改善に役立ちますが、個人情報に注意します

検索ログを取ると、利用者が何を探しているか分かります。0件キーワードを分析すれば、コンテンツ不足や表記揺れも見つけやすくなります。

保存したい情報の例:

  • 検索キーワード
  • 件数
  • 絞り込み条件
  • 検索日時
  • ユーザーID
  • セッションID
  • クリックされた結果

ただし、検索キーワードには個人情報が含まれることがあります。氏名、メールアドレス、電話番号、住所などが入力される可能性があります。
そのため、保存期間、マスキング、アクセス権限、削除方針を決めておくと安心です。

検索ログは便利ですが、利用者のプライバシーとセットで考える必要があります。


16. キャッシュ:検索結果のキャッシュは慎重に使います

検索結果は条件が多く、ユーザーごとに変わることもあります。何でもキャッシュすると、古い結果や別条件の結果が混ざる原因になります。

キャッシュするなら、キーに条件を含めます。

$key = sprintf(
    'posts:search:q:%s:category:%s:page:%d',
    md5($request->input('q', '')),
    $request->input('category', 'all'),
    $request->integer('page', 1)
);

ただし、検索結果全体をキャッシュするより、カテゴリ一覧や人気キーワード、検索補助データをキャッシュする方が安全な場合も多いです。
検索は更新頻度や条件数が多いため、キャッシュ設計は慎重に進めましょう。


17. 検索UIのアクセシビリティ:検索前、検索中、検索後を丁寧に設計します

検索UIでは、次の3つの状態を明確にします。

17.1 検索前

検索欄にはラベルを付けます。プレースホルダーだけに頼りません。

<label for="q">記事を検索</label>
<input id="q" name="q" type="search">

17.2 検索中

LivewireやAjaxで動的検索をする場合、読み込み中を伝えます。

<div role="status" aria-live="polite">
    検索中です。
</div>

また、結果一覧の領域には aria-busy を使うこともできます。

<section aria-busy="true">
    <p class="sr-only">検索結果を読み込み中です。</p>
</section>

17.3 検索後

件数、条件、0件時の提案を表示します。
検索結果の見出しも明確にします。

<h2>検索結果</h2>
<p role="status">「Laravel」の検索結果:12件</p>

検索は操作の中心になるため、状態が分かることがとても大切です。


18. Livewireでリアルタイム検索を作る場合の注意点

Livewireを使うと、入力に応じて検索結果をリアルタイムに更新できます。便利ですが、更新が頻繁すぎると、画面が落ち着かず、読み上げでも混乱しやすくなります。

おすすめは debounce を使うことです。

<input
    id="q"
    type="search"
    wire:model.live.debounce.300ms="q"
>

結果件数は role="status" で伝えます。

<p role="status" aria-live="polite">
    {{ $results->total() }}件見つかりました。
</p>

ただし、毎文字入力のたびに読み上げが走ると負担になることもあります。
リアルタイム検索では、更新頻度、読み上げ、フォーカス移動を慎重に調整しましょう。
複雑な検索では、あえて「検索する」ボタンを押す方式の方が分かりやすい場合もあります。


19. テスト:検索条件、0件、権限を守ります

検索機能は条件分岐が多いため、テストが重要です。

19.1 キーワード検索のテスト

public function test_posts_can_be_searched_by_keyword()
{
    Post::factory()->create(['title' => 'Laravel入門']);
    Post::factory()->create(['title' => 'PHP基礎']);

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

    $response->assertOk()
        ->assertSee('Laravel入門')
        ->assertDontSee('PHP基礎');
}

19.2 0件表示のテスト

public function test_no_results_message_is_displayed()
{
    $response = $this->get('/posts?q=notfoundkeyword');

    $response->assertOk()
        ->assertSee('検索結果が見つかりませんでした');
}

19.3 非公開データが出ないテスト

public function test_unpublished_posts_are_not_displayed_in_public_search()
{
    Post::factory()->create([
        'title' => '非公開の記事',
        'status' => 'draft',
    ]);

    $response = $this->get('/posts?q=非公開');

    $response->assertOk()
        ->assertDontSee('非公開の記事');
}

検索は「見つかること」だけでなく、「見えてはいけないものが出ないこと」もテストします。


20. よくある落とし穴と回避策

20.1 何でも LIKE "%keyword%" にする

小規模なら便利ですが、大量データでは遅くなります。
必要に応じて全文検索やScoutを検討します。

20.2 並び替えカラムをそのまま受け取る

危険です。
許可リスト方式で制御します。

20.3 検索条件がページングで消える

withQueryString() を使い、条件を保持します。

20.4 0件表示が不親切

「結果がありません」だけでなく、次の検索方法を提案します。

20.5 検索インデックスに非公開情報を入れる

検索対象にする情報は慎重に選びます。
権限付きデータは特に注意します。

20.6 リアルタイム検索で更新が多すぎる

debounce を使い、読み上げや認知負荷に配慮します。


21. チェックリスト(配布用)

検索方式

  • [ ] LIKE 検索で十分か確認した
  • [ ] 全文検索やScoutが必要な要件を整理した
  • [ ] Meilisearch / Algolia 導入時の運用負荷を確認した

入力・条件

  • [ ] 検索フォームはGETメソッド
  • [ ] 検索欄にラベルがある
  • [ ] FormRequestで検索条件を検証している
  • [ ] 並び替えは許可リスト方式

検索結果

  • [ ] 件数を表示している
  • [ ] 現在の検索条件を表示している
  • [ ] 0件時に次の行動を提案している
  • [ ] ページングで条件を保持している

権限・安全性

  • [ ] 非公開データが検索結果に出ない
  • [ ] 検索インデックスに秘密情報を入れていない
  • [ ] 権限付き検索の設計がある

アクセシビリティ

  • [ ] role="search" を適切に使っている
  • [ ] 結果件数を role="status" で伝えている
  • [ ] 読み込み中の状態をテキストで伝えている
  • [ ] 色だけで状態を示していない

運用・テスト

  • [ ] 検索インデックスの再構築手順がある
  • [ ] 検索ログの保存方針がある
  • [ ] キーワード検索、0件、非公開除外のテストがある

22. まとめ

Laravelの検索機能は、最初はシンプルな LIKE 検索から始められます。しかし、データ量、検索対象、関連度、絞り込み、権限、多言語、運用要件が増えるにつれて、設計の重要性が高まります。
小さな管理画面ならEloquent検索で十分なこともありますし、公開サイトやECのように検索体験が重要な場合は、Laravel Scout、Meilisearch、Algoliaなどを検討する価値があります。

大切なのは、検索方式だけでなく、検索UIも丁寧に作ることです。検索フォームにはラベルを付け、検索結果件数を示し、0件時には次の行動を提案し、読み込み中やエラーも分かりやすく伝えます。検索は、利用者が目的の情報にたどり着くための道案内です。速く、正確で、そして誰にでも使いやすい検索機能を、Laravelで少しずつ育てていきましょう。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

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