【実務完全ガイド】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の where と LIKE で十分なことが多いです。
$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"、label、type="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ページ目へ移動しても q や category などの条件が維持されます。
条件が消えると、利用者は「検索結果の続きを見たつもりが、全件一覧に戻っていた」という混乱を感じます。小さなことですが、検索体験ではとても重要です。
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で少しずつ育てていきましょう。

