【第四弾】Laravel入門資料

📚 Laravel公式ドキュメント - Requests

https://laravel.com/docs/12.x/requests

💡 この講座では、上記の公式ドキュメントを基に解説していきます。
公式ドキュメントは初学者には内容が難しいため、エッセンスを優しく噛み砕いて解説していきます。

検索・フィルタリング - ユーザーが欲しいデータを見つける

商品が増えてくると、ユーザーが欲しい商品を見つけにくくなります。
検索機能とフィルタリング機能を実装して、データを絞り込む方法を学びます。

🎯 この章で学ぶこと

  • ✅ キーワード検索 - where()like の使い方
  • ✅ カテゴリーフィルタリング - プルダウンで絞り込み
  • ✅ 複数条件の組み合わせ - when() メソッド
  • ✅ 検索フォームの作成とGETリクエストの処理
  • ✅ 検索条件の保持 - request()->input()

💡 前提条件

  • products テーブルと categories テーブルが作成済み
  • ✅ Product モデルと Category モデルが作成済み
  • ✅ リレーション(belongsTo)が設定済み

※ 「ここまでの知識の実践」で作成したテーブルとモデルをそのまま使用します

🗄️ テストデータの準備 - ページネーション動作確認用

検索・フィルタ・ページネーション機能を確認するには、十分なテストデータが必要です。
paginate(10)を使う場合、ページネーションを表示するには11件以上のデータが必要になります。

推奨: 13件のテストデータを作成(公開12件 + 非公開1件)

📝 ProductSeeder.php の編集

database/seeders/ProductSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Product;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        $products = [
            ['name' => 'ノートPC', 'category_id' => 1, 'price' => 89800, 'stock' => 15, 'is_published' => true, 'description' => '高性能ノートパソコン'],
            ['name' => 'デスクトップPC', 'category_id' => 1, 'price' => 120000, 'stock' => 8, 'is_published' => true, 'description' => 'ゲーミングPC'],
            ['name' => 'マウス', 'category_id' => 1, 'price' => 2980, 'stock' => 50, 'is_published' => true, 'description' => 'ワイヤレスマウス'],
            ['name' => 'キーボード', 'category_id' => 1, 'price' => 8900, 'stock' => 30, 'is_published' => true, 'description' => 'メカニカルキーボード'],
            ['name' => 'モニター', 'category_id' => 1, 'price' => 34800, 'stock' => 12, 'is_published' => true, 'description' => '27インチ4Kモニター'],
            ['name' => 'Tシャツ', 'category_id' => 2, 'price' => 2900, 'stock' => 100, 'is_published' => true, 'description' => 'コットン100%'],
            ['name' => 'ジーンズ', 'category_id' => 2, 'price' => 5980, 'stock' => 45, 'is_published' => true, 'description' => 'デニムパンツ'],
            ['name' => 'スニーカー', 'category_id' => 2, 'price' => 7800, 'stock' => 20, 'is_published' => true, 'description' => 'ランニングシューズ'],
            ['name' => '大学ノート', 'category_id' => 3, 'price' => 180, 'stock' => 200, 'is_published' => true, 'description' => 'B5サイズ'],
            ['name' => 'ボールペン', 'category_id' => 3, 'price' => 120, 'stock' => 300, 'is_published' => true, 'description' => '黒・赤・青'],
            ['name' => 'クリアファイル', 'category_id' => 3, 'price' => 100, 'stock' => 150, 'is_published' => true, 'description' => 'A4サイズ'],
            ['name' => 'マグカップ', 'category_id' => 4, 'price' => 1200, 'stock' => 80, 'is_published' => true, 'description' => '陶器製'],
            ['name' => 'スマートウォッチ', 'category_id' => 1, 'price' => 45000, 'stock' => 0, 'is_published' => false, 'description' => '在庫切れ・非公開テスト用'],
        ];

        foreach ($products as $product) {
            Product::create($product);
        }
    }
}

💡 このデータの特徴

  • 13件のデータ → ページネーション表示確認可能(2ページ目が表示される)
  • 「ノート」キーワード → 「ノートPC」「大学ノート」の2件がヒット
  • 複数カテゴリー → フィルタリング動作確認可能
  • 1件非公開(is_published = false) → 検索結果に含まれない動作確認
  • 在庫0の商品 → 在庫フィルター実装時に利用可能

🚀 シーダー実行コマンド

# データベースをリセットして再シード
php artisan migrate:fresh --seed

# または ProductSeeder のみ実行
php artisan db:seed --class=ProductSeeder

⚠️ 注意

migrate:fresh全データが削除されます。
本番環境では絶対に実行しないでください!

検索・フィルタリング機能の例

実装する機能

  • キーワード検索: 商品名で部分一致検索
  • カテゴリーフィルター: プルダウンでカテゴリーを選択
  • 検索条件の保持: 検索後もフォームに入力内容を保持
  • 検索とページネーションの組み合わせ

GETリクエストとクエリパラメータ

検索機能ではGETリクエストを使い、検索条件をクエリパラメータ(URLパラメータ)で渡します。

💡 クエリパラメータとは?

URLの?以降に付けるパラメータのことです。
例: /search?keyword=ノート&category_id=1

  • ? の後にパラメータを記述
  • キー=値 の形式
  • • 複数のパラメータは & で繋ぐ

✅ GETリクエストを使うメリット

  • • URLに検索条件が表示される → ブックマーク可能
  • • URLをシェアできる(「この検索結果を見て!」)
  • • ブラウザの戻るボタンが使える
  • • ページネーションと組み合わせやすい

クエリパラメータの取得方法

// コントローラーでの取得方法
$keyword = $request->input('keyword');
$categoryId = $request->input('category_id');

// デフォルト値を指定することも可能
$keyword = $request->input('keyword', '');  // デフォルトは空文字

LIKE検索を理解する

部分一致検索を実装する前に、LIKE検索について詳しく学びましょう。

Tinkerで試してみよう

php artisan tinker

1. 部分一致検索(前後に%)

// 「ノート」を含む商品を検索
Product::where('name', 'like', '%ノート%')->get();

// 実行されるSQL: SELECT * FROM products WHERE name LIKE '%ノート%'
// ヒットする例: 「ノートPC」「大学ノート」「ノート型パソコン」

2. 前方一致検索(後ろに%)

// 「ノート」で始まる商品を検索
Product::where('name', 'like', 'ノート%')->get();

// ヒットする例: 「ノートPC」「ノート型パソコン」
// ヒットしない例: 「大学ノート」(ノートが先頭にない)

3. 後方一致検索(前に%)

// 「ノート」で終わる商品を検索
Product::where('name', 'like', '%ノート')->get();

// ヒットする例: 「大学ノート」「方眼ノート」
// ヒットしない例: 「ノートPC」(ノートが末尾にない)

4. 完全一致検索(%なし)

// 「ノート」と完全一致する商品を検索
Product::where('name', 'like', 'ノート')->get();

// これは where('name', '=', 'ノート') と同じ
// ヒットする例: 「ノート」のみ

⚠️ LIKE検索のパフォーマンス注意

前方に%がある場合(%キーワード%%キーワード)は、
インデックスが使われず、全件スキャンになるため遅くなります。
大量データの場合は、全文検索エンジン(Elasticsearch等)の利用を検討しましょう。

基本的なキーワード検索の実装

それでは、学んだLIKE検索を使って商品検索機能を実装します。

ステップ1: ルーティングの設定

routes/web.php

use App\Http\Controllers\SearchController;

Route::get('/search', [SearchController::class, 'index']);

ステップ2: コントローラーの作成

検索機能を持つコントローラーを作成します。

php artisan make:controller SearchController

app/Http/Controllers/SearchController.php を以下のように編集します。

<?php

namespace App\Http\Controllers;

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

class SearchController extends Controller
{
    public function index(Request $request)
    {
        // リクエストからキーワードを取得
        $keyword = $request->input('keyword');

        // 検索クエリを構築
        $query = Product::with('category');

        // キーワードがある場合のみ検索条件を追加
        if ($keyword) {
            $query->where('name', 'like', "%{$keyword}%");
        }

        // ページネーション付きで取得
        $products = $query->paginate(10);

        return view('search.index', compact('products', 'keyword'));
    }
}

💡 LIKE検索とは?

where('name', 'like', "%{$keyword}%") は部分一致検索を行います。
% はワイルドカード(任意の文字列)を表します。

  • "%キーワード%" → 前後に任意の文字(部分一致)
  • "キーワード%" → 前方一致
  • "%キーワード" → 後方一致

ステップ3: ビューファイルの作成

resources/views/search/index.blade.php を作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品検索</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        .search-form { margin-bottom: 20px; }
        .search-form input { padding: 8px; width: 300px; }
        .search-form button { padding: 8px 16px; background: #4CAF50; color: white; border: none; cursor: pointer; }
        table { width: 100%; border-collapse: collapse; margin: 20px 0; }
        th, td { border: 1px solid #ddd; padding: 12px; text-left; }
        th { background-color: #4CAF50; color: white; }
    </style>
</head>
<body>
    <h1>商品検索</h1>

    {{-- 検索フォーム --}}
    <form method="GET" action="/search" class="search-form">
        <input type="text" name="keyword" placeholder="商品名で検索" value="{{ $keyword }}">
        <button type="submit">検索</button>
    </form>

    <p>検索結果: {{ $products->total() }}件</p>

    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>商品名</th>
                <th>カテゴリー</th>
                <th>価格</th>
                <th>在庫</th>
            </tr>
        </thead>
        <tbody>
            @forelse($products as $product)
            <tr>
                <td>{{ $product->id }}</td>
                <td>{{ $product->name }}</td>
                <td>{{ $product->category->name }}</td>
                <td>¥{{ number_format($product->price) }}</td>
                <td>{{ $product->stock }}</td>
            </tr>
            @empty
            <tr>
                <td colspan="5" style="text-align: center;">該当する商品が見つかりませんでした</td>
            </tr>
            @endforelse
        </tbody>
    </table>

    {{-- ページネーションリンク(検索条件を保持) --}}
    {{ $products->appends(['keyword' => $keyword])->links() }}
</body>
</html>

💡 重要なポイント

  • <script src="https://cdn.tailwindcss.com"></script> - Tailwind CSS CDN(ページネーションのスタイルに必須)
  • value="{{ $keyword }}" - 検索後も入力値を保持
  • @forelse ... @empty - データがない場合の表示
  • $products->total() - 検索結果の総件数を表示
  • appends(['keyword' => $keyword]) - ページネーションに検索条件を追加

⚠️ Tailwind CDNが必要な理由

Laravel 12のページネーション(links())は、デフォルトでTailwind CSSのクラスを使用しています。

もしTailwind CSSを読み込まないと:

  • ページネーションのボタン(< >)のスタイルが崩れる
  • レイアウトが正しく表示されない

本番環境では、CDNではなくViteでビルドしたTailwind CSSを使うことを推奨します。

value属性で検索値を保持する仕組み

value="{{ $keyword }}" がないと、検索後にフォームが空になってしまいます。

🔍 実際の動作例

ユーザーが「ノート」と入力して検索ボタンをクリックした時:

  1. ブラウザが /search?keyword=ノート にGETリクエスト
  2. コントローラーが $keyword = $request->input('keyword'); で「ノート」を取得
  3. 検索実行後、ビューに $keyword(値は「ノート」)を渡す
  4. Bladeテンプレートで value="{{ $keyword }}"value="ノート" に変換される
  5. 検索フォームに「ノート」が表示されたままになる ✅

❌ value属性がない場合

<input type="text" name="keyword" placeholder="商品名で検索">

→ 検索後、フォームが空になってしまう
→ 「何で検索したんだっけ?」となる(ユーザー体験が悪い)

✅ value属性がある場合

<input type="text" name="keyword" placeholder="商品名で検索" value="{{ $keyword }}">

→ 検索後、フォームに「ノート」が残る
→ 検索条件を確認・修正しやすい(ユーザー体験が良い)

💡 初回アクセス時の動作

初めて/searchにアクセスした時は、$keywordnullなので、
value=""(空文字)になり、フォームは空で表示されます。

ステップ4: 確認

ブラウザで /search にアクセスして確認します。

検索フォームでキーワードを入力して検索してみましょう。

✅ 基本的なキーワード検索機能が完成しました!

カテゴリーフィルタリングの追加

キーワード検索に加えて、カテゴリーでフィルタリングできるようにします。

コントローラーの更新

app/Http/Controllers/SearchController.php

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Models\Category;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    public function index(Request $request)
    {
        // リクエストから検索条件を取得
        $keyword = $request->input('keyword');
        $categoryId = $request->input('category_id');

        // 検索クエリを構築
        $query = Product::with('category');

        // キーワード検索
        if ($keyword) {
            $query->where('name', 'like', "%{$keyword}%");
        }

        // カテゴリーフィルター
        if ($categoryId) {
            $query->where('category_id', $categoryId);
        }

        // ページネーション付きで取得
        $products = $query->paginate(10);

        // カテゴリー一覧を取得(プルダウン用)
        $categories = Category::all();

        return view('search.index', compact('products', 'keyword', 'categoryId', 'categories'));
    }
}

ビューの更新

検索フォームにカテゴリープルダウンを追加します。

{{-- 検索フォーム --}}
<form method="GET" action="/search" class="search-form">
    <input type="text" name="keyword" placeholder="商品名で検索" value="{{ $keyword }}">

    <select name="category_id">
        <option value="">すべてのカテゴリー</option>
        @foreach($categories as $category)
            <option value="{{ $category->id }}" {{ $categoryId == $category->id ? 'selected' : '' }}>
                {{ $category->name }}
            </option>
        @endforeach
    </select>

    <button type="submit">検索</button>
</form>

{{-- ページネーションリンク(検索条件を保持) --}}
{{ $products->appends(['keyword' => $keyword, 'category_id' => $categoryId])->links() }}

ページネーション表示のカスタマイズ

Laravel 12のlinks()は、デフォルトで英語の翻訳Tailwind CSSのスタイルを使用します。
日本語表示を自然にするには、翻訳ファイルとビューファイルのカスタマイズが必要です。

📝 ページネーションの表示問題

links()を使うと、以下のような問題が発生することがあります:

  • 「表示中 1 に 10 の 13 結果」という不自然な日本語
  • ボタン(< >)のスタイルが崩れる

解決策1: ページネーションビューのカスタマイズ(推奨)

ページネーションビューを公開して、日本語の文章構造に合わせて編集します。

# ページネーションビューを公開
php artisan vendor:publish --tag=laravel-pagination

公開先: resources/views/vendor/pagination/tailwind.blade.php

編集箇所: 26行目付近の表示テキスト部分

{{-- 変更前 --}}
<p class="text-sm text-gray-700 leading-5 dark:text-gray-400">
    {!! __('Showing') !!}
    @if ($paginator->firstItem())
        <span class="font-medium">{{ $paginator->firstItem() }}</span>
        {!! __('to') !!}
        <span class="font-medium">{{ $paginator->lastItem() }}</span>
    @else
        {{ $paginator->count() }}
    @endif
    {!! __('of') !!}
    <span class="font-medium">{{ $paginator->total() }}</span>
    {!! __('results') !!}
</p>

{{-- 変更後(自然な日本語) --}}
<p class="text-sm text-gray-700 leading-5 dark:text-gray-400">
    全 <span class="font-medium">{{ $paginator->total() }}</span> 件中
    @if ($paginator->firstItem())
        <span class="font-medium">{{ $paginator->firstItem() }}</span> 〜
        <span class="font-medium">{{ $paginator->lastItem() }}</span> 件を表示
    @else
        {{ $paginator->count() }} 件を表示
    @endif
</p>

✅ カスタマイズ後の表示

「全 13 件中 1 〜 10 件を表示」のような自然な日本語になります。

解決策2: 翻訳ファイルの編集(簡易的)

lang/ja.jsonを編集して翻訳を調整します(完全には自然な日本語にならない)。

{
    "Showing": "表示中",
    "to": "〜",
    "of": "件中",
    "results": "件"
}

→ 「表示中 1 〜 10 件中 13 件」となり、やや改善されますが完璧ではありません。

links()の仕組み - どうやってHTMLが生成されるのか

{{ $products->links() }} と書くだけでページネーションが表示される裏側の仕組みを理解しましょう。

🔄 links()の内部動作フロー

  1. Bladeで呼び出し: {{ $products->links() }}
  2. どのビューを使うか判定: デフォルトは tailwind.blade.php
  3. ビューファイルを探す優先順位:
    • resources/views/vendor/pagination/tailwind.blade.php ← まずここを探す
    • vendor/laravel/framework/.../pagination/.../tailwind.blade.php ← なければフレームワーク内のデフォルトを使う
  4. $paginator オブジェクトを渡してレンダリング: ページ情報を含むオブジェクトがビューに渡される
  5. HTML出力: ボタンやページ番号のHTMLが生成される

💡 $paginator オブジェクトで使えるメソッド

ビューファイル内で以下のメソッドが使えます:

$paginator->total()       // 総件数: 13
$paginator->firstItem()   // 現在ページの最初の番号: 1
$paginator->lastItem()    // 現在ページの最後の番号: 10
$paginator->currentPage() // 現在のページ番号: 1
$paginator->hasPages()    // ページが複数あるか: true/false
$paginator->hasMorePages() // 次のページがあるか: true/false

他のページネーションビューを使う

Tailwind以外のスタイルも選択できます。

// デフォルト(Tailwind CSS)
{{ $products->links() }}

// Bootstrap 5
{{ $products->links('pagination::bootstrap-5') }}

// シンプル版(前へ・次へのみ)
{{ $products->links('pagination::simple-tailwind') }}

✅ なぜ vendor:publish が必要なのか

デフォルトのビューはvendor/ディレクトリ内にあり、直接編集するとComposerアップデート時に上書きされます。
php artisan vendor:publish --tag=laravel-paginationでコピーすることで、
resources/views/vendor/pagination/に配置され、安全にカスタマイズできます。

appends()でページネーションに検索条件を追加

ページネーションのリンクをクリックした時、検索条件が消えてしまう問題を解決します。

⚠️ appends()がない場合の問題

「ノート」で検索して、2ページ目に移動した時:

  1. 1ページ目: /search?keyword=ノート → 「ノート」の検索結果が表示される ✅
  2. 2ページ目のリンクをクリック: /search?page=2 になる
  3. 2ページ目: keywordパラメータが消えている → 全商品が表示される ❌
  4. 検索条件が失われる → ユーザー体験が悪い 😢

❌ appends()なしの場合

{{ $products->links() }}

→ ページネーションのURLが /search?page=2 になる
→ 検索条件(keyword, category_id)が消える

✅ appends()ありの場合

{{ $products->appends(['keyword' => $keyword, 'category_id' => $categoryId])->links() }}

→ ページネーションのURLが /search?keyword=ノート&category_id=1&page=2 になる
→ 検索条件が保持される ✅

💡 appends()の仕組み

appends()は、ページネーションリンクに追加のクエリパラメータを付与します。

// 生成されるページネーションリンク
1ページ目: /search?keyword=ノート&category_id=1&page=1
2ページ目: /search?keyword=ノート&category_id=1&page=2
3ページ目: /search?keyword=ノート&category_id=1&page=3

💡 複数条件の保持

appends() に配列で複数の条件を渡すことで、
ページネーション時にすべての検索条件を保持できます。

📝 補足: ページネーションの詳細

ページネーションの詳しい仕組みは、次の「ページネーション」タブで学習します。
ここでは「appends()を使うと検索条件が保持される」と理解しておけばOKです!

✅ カテゴリーフィルタリング機能が追加できました!

when()メソッドでコードをスッキリさせる

if 文で条件分岐する代わりに、when() メソッドを使うとコードがスッキリします。

💡 when()とは?

Laravelのwhen()は、クエリビルダーに条件を動的に追加するためのメソッドです。

検索条件の有無によってクエリを変えたい時に、if文を書かずにスッキリと記述できます。

⚠️ SQLのCASE WHENとは別物

SQLにもWHENがありますが、用途が全く異なります:

SQLのCASE WHEN

値の条件分岐

SELECT
  CASE
    WHEN price < 1000 THEN '安い'
    WHEN price < 5000 THEN '普通'
    ELSE '高い'
  END
FROM products

価格帯の判定など

Laravelのwhen()

クエリの条件分岐

Product::when($keyword,
  function ($q, $keyword) {
    return $q->where(
      'name', 'like',
      "%{$keyword}%"
    );
  }
)->get()

検索条件の動的追加

Tinkerでwhen()を理解する

php artisan tinker

1. 基本的な使い方

// 条件がtrueの時だけクエリを追加
$keyword = 'ノート';
Product::when($keyword, function ($query, $keyword) {
    return $query->where('name', 'like', "%{$keyword}%");
})->get();

// $keywordが存在する → where条件が追加される
// 実行されるSQL: SELECT * FROM products WHERE name LIKE '%ノート%'

2. 条件がfalseの場合

// 条件がnullや空文字の場合、when内は実行されない
$keyword = null;  // または ''
Product::when($keyword, function ($query, $keyword) {
    return $query->where('name', 'like', "%{$keyword}%");
})->get();

// $keywordがnull → when内は実行されない
// 実行されるSQL: SELECT * FROM products  (条件なし)

3. 複数のwhenを繋げる

$keyword = 'ノート';
$categoryId = 1;

Product::when($keyword, function ($query, $keyword) {
        return $query->where('name', 'like', "%{$keyword}%");
    })
    ->when($categoryId, function ($query, $categoryId) {
        return $query->where('category_id', $categoryId);
    })
    ->get();

// 実行されるSQL:
// SELECT * FROM products
// WHERE name LIKE '%ノート%' AND category_id = 1

4. if文との比較

// ❌ if文を使った場合(冗長)
$query = Product::query();

if ($keyword) {
    $query->where('name', 'like', "%{$keyword}%");
}

if ($categoryId) {
    $query->where('category_id', $categoryId);
}

$products = $query->get();

// ✅ when()を使った場合(スッキリ)
$products = Product::when($keyword, function ($query, $keyword) {
        return $query->where('name', 'like', "%{$keyword}%");
    })
    ->when($categoryId, function ($query, $categoryId) {
        return $query->where('category_id', $categoryId);
    })
    ->get();

💡 when()の仕組み

when(条件, コールバック) は以下のように動作します:

  • • 第1引数が truthy(true、非0、非null、非空文字)→ コールバック実行
  • • 第1引数が falsy(false0null'')→ 何もしない
  • • 第2引数のコールバックには、$query条件の値が渡される

✅ when()のメリット

  • if 文を書かなくていい → コード量が減る
  • • メソッドチェーンで書ける → 読みやすい
  • • 条件が多くなってもスッキリ → メンテナンスしやすい
  • • Laravelらしい書き方 → チームで統一しやすい

コントローラーでの実装例

public function index(Request $request)
{
    $keyword = $request->input('keyword');
    $categoryId = $request->input('category_id');

    // when()メソッドで条件付きクエリを構築
    $products = Product::with('category')
        ->when($keyword, function ($query, $keyword) {
            return $query->where('name', 'like', "%{$keyword}%");
        })
        ->when($categoryId, function ($query, $categoryId) {
            return $query->where('category_id', $categoryId);
        })
        ->paginate(10);

    $categories = Category::all();

    return view('search.index', compact('products', 'keyword', 'categoryId', 'categories'));
}

✅ より洗練されたコードになりました!

まとめ

検索・フィルタリング機能の実装を学びました!

学んだこと

  • where('name', 'like', "%{$keyword}%") でキーワード検索
  • where('category_id', $categoryId) でカテゴリーフィルター
  • when() メソッドで条件付きクエリをスッキリ記述
  • appends() でページネーション時に検索条件を保持
  • @forelse ... @empty で検索結果0件の表示

次のステップ

検索・フィルタリング機能を習得しました。
次は「ソフトデリート」タブで、データを論理削除する方法を学習しましょう!

✅ 検索・フィルタリング学習完了です!