【Laravel】クエリビルダで検索＋絞り込み(カテゴリ)機能を作る方法を解説

この記事では以下のバージョンを利用しています。また、環境構築が出来ている前提です。

PHP 7.3.11
Larvel5.8
MySQL5.7
開発環境：Mac virtualbox + docker

#要件定義

今回はクエリビルダを用いて、以下の4通りのパターンの検索機能の実装方法を解説します！

**1.何も入力せずに全件表示**
**2.商品名を入力して検索**
**3.カテゴリ(肉類or魚介類)を選択して表示**
**4.商品名を入力かつカテゴリを選択して絞り込む(and検索)**

その他にも件数の表示、ページネーション、セキュリティ対策を実装します。


**1.何も入力せずに全件表示** 
![スクリーンショット 2020-12-29 14.50.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/dc6d6300-8f26-98ab-71c6-821394d42ba6.png)

**2.商品名を入力して検索**
![スクリーンショット 2020-12-29 14.55.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/7f1c1f4a-801d-d434-01ac-d0f3eb92bb16.png)


**3.カテゴリ(肉類or魚介類)を選択して表示** 
![スクリーンショット 2020-12-29 15.00.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/c820743e-296a-754d-17a7-237c8c128b99.png)


**4.商品名を入力かつカテゴリを選択して絞り込む(and検索)**
※画像は「い」を入力して「肉類」を選択している状態
![スクリーンショット 2020-12-29 15.02.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/09cbbede-8c9e-9755-0399-56762574b6b4.png)

#マイグレーションファイルとシーダーを作成


今回必要なデータベースは
・m_productsテーブルの**id、product_name、category_id、price**と、
・m_categoriesテーブルの**id、category_name**です。
尚、テーブルやファイル名にm_などがついておりますが、これは制作の都合によるものです。

####商品情報のテーブルとシーダーを作成
```php:xxxx_xx_xx_xxxxxx_create_m_products.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMProducts extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('m_products', function (Blueprint $table) {
            $table->increments('id');
            $table->string('product_name', 64);
            $table->integer('category_id')->unsigned();
            $table->integer('price')->unsigned();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('m_products');
    }
}
```

```M_ProductsSeeder.php
<?php

use Illuminate\Database\Seeder;

class M_ProductsSeeder extends Seeder
{
   /**
    * Run the database seeds.
    *
    * @return void
    */
   public function run()
   {
      DB::table('m_products')->insert([
         'id' => 1,
         'product_name' => '黒毛和牛サーロイン',
         'category_id' => 1,
         'price' => 8000
      ]);
      DB::table('m_products')->insert([
         'id' => 2,
         'product_name' => 'A5ランク松坂牛',
         'category_id' => 1,
         'price' => 12000
      ]);
      DB::table('m_products')->insert([
         'id' => 3,
         'product_name' => 'フィレステーキ',
         'category_id' => 1,
         'price' => 5000
      ]);
      DB::table('m_products')->insert([
         'id' => 4,
         'product_name' => '越前ガニ',
         'category_id' => 2,
         'price' => 6000
      ]);
      DB::table('m_products')->insert([
         'id' => 5,
         'product_name' => '特選いくら',
         'category_id' => 2,
         'price' => 4000
      ]);
   }
}
```

####カテゴリーのテーブルとシーダーを作成
```xxxx_xx_xx_xxxxxx_create_m_categories.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMCategories extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('m_categories', function (Blueprint $table) {
            $table->increments('id');
            $table->string('category_name', 32);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('m_categories');
    }
}
```

```M_CategoriesSeeder.php
<?php

use Illuminate\Database\Seeder;

class M_CategoriesSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('m_categories')->insert([
            'id' => 1,
            'category_name' => '肉類',
        ]);
        DB::table('m_categories')->insert([
            'id' => 2,
            'category_name' => '魚介類',
        ]);
    }
}
```

#モデルの作成

次にモデルを作成します。今回は**「カテゴリは複数の商品を持つ(hasMany)」、「商品はカテゴリに属する(belongsTo)」**というリレーション関係を作りたいので、以下のように記述します。

```MCategory.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class MCategory extends Model
{
    //m_categoriesテーブルから::pluckでcategory_nameとidを抽出し、$categoriesに返す関数を作る
    public function getLists()
    {
        $categories = MCategory::pluck('category_name', 'id');

        return $categories;
    }
    //「カテゴリ(category)はたくさんの商品(products)をもつ」というリレーション関係を定義する
    public function products()
    {
        return $this->hasMany(MProduct::class);
    }
}

```

```MProduct.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class MProduct extends Model
{
    //「商品(products)はカテゴリ(category)に属する」というリレーション関係を定義する
    public function category()
    {
        return $this->belongsTo(MCategory::class);
    }
}
```

ちなみに、モデルのファイル名を[命名規則][link-1]通りに付けることで、テーブルと勝手に接続してくれます。Laravelの便利機能ですね。
[link-1]:https://laraweb.net/knowledge/942/

#商品検索画面へのルーティングを作成

この検索画面では、検索フォームだけを表示する**show**と、検索を実行し結果を表示する**searchproduct**の2つのルーティングを作成します。

```web.php
Route::get('show', 'ProductController@show')->name('show');

Route::get('searchproduct', 'ProductController@search')->name('searchproduct');
```

#コントローラの作成
まずは検索フォームだけを表示するshowメソッドを作ります。フォームを機能させるため変数を記述し、viewにreturnしています。

```ProductController.php
<?php

namespace App\Http\Controllers;


use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

use App\MProduct;
use App\MCategory;


class ProductController extends Controller
{
    /*==================================
    検索フォームのみ表示(show)
    ==================================*/
    public function show(Request $request)
    {
        //フォームを機能させるために各情報を取得し、viewに返す
        $category = new MCategory;
        $categories = $category->getLists();
        $searchWord = $request->input('searchWord');
        $categoryId = $request->input('categoryId');

        return view('searchproduct', [
            'categories' => $categories,
            'searchWord' => $searchWord,
            'categoryId' => $categoryId
        ]);
    }
}

```

viewもフォーム部分を作成します。

```searchproduct.blade.php
@extends('layouts.app')

@section('content')

<main>
  <div class="container">
    <div class="mx-auto">
      <br>
      <h2 class="text-center">商品検索画面</h2>
      <br>
      <!--検索フォーム-->
      <div class="row">
        <div class="col-sm">
          <form method="GET" action="{{ route('searchproduct')}}">
            <div class="form-group row">
              <label class="col-sm-2 col-form-label">商品名</label>
              <!--入力-->
              <div class="col-sm-5">
                <input type="text" class="form-control" name="searchWord" value="{{ $searchWord }}">
              </div>
              <div class="col-sm-auto">
                <button type="submit" class="btn btn-primary ">検索</button>
              </div>
            </div>     
            <!--プルダウンカテゴリ選択-->
            <div class="form-group row">
              <label class="col-sm-2">商品カテゴリ</label>
              <div class="col-sm-3">
                <select name="categoryId" class="form-control" value="{{ $categoryId }}">
                  <option value="">未選択</option>

                  @foreach($categories as $id => $category_name)
                  <option value="{{ $id }}">
                    {{ $category_name }}
                  </option>  
                  @endforeach
                </select>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

  </div>
</main>

@endsection
```

~/show
![スクリーンショット 2020-12-29 17.01.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/6f032cd3-4b02-bb54-ef82-feb742beb540.png)


#クエリビルダで検索メソッドを作成

ここから本題のクエリビルダを用いた検索機能の作成方法について解説します。
そもそもクエリビルダとは何か？は以下のサイトが分かりやすく説明してくれています。一部引用です。
>クエリビルダとは、データベースからレコードを取得する際に SQL 文を組み立てて問い合わせを行いますが、それを簡単に組み立てる事の出来る機能です。
SQL 文に詳しくなくても、この機能によって適切な書式で問い合わせを行える為、とても便利な機能です。[参考][link-2]
[link-2]:https://www.ritolab.com/entry/93

```ProductController.php
<?php

namespace App\Http\Controllers;


use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

use App\MProduct;
use App\MCategory;


class ProductController extends Controller
{
    /*==================================
    検索フォームのみ表示(show)
    ==================================*/
    public function show(Request $request)
    {
        //フォームを機能させるために各情報を取得し、viewに返す
        $category = new MCategory;
        $categories = $category->getLists();
        $searchWord = $request->input('searchWord');
        $categoryId = $request->input('categoryId');

        return view('searchproduct', [
            'categories' => $categories,
            'searchWord' => $searchWord,
            'categoryId' => $categoryId
        ]);
    }

　　　//以下追記
    /*==================================
    検索メソッド(searchproduct)
    ==================================*/
    public function search(Request $request)
    {
        //入力される値nameの中身を定義する
        $searchWord = $request->input('searchWord'); //商品名の値
        $categoryId = $request->input('categoryId'); //カテゴリの値

        $query = MProduct::query();
        //商品名が入力された場合、m_productsテーブルから一致する商品を$queryに代入
        if (isset($searchWord)) {
            $query->where('product_name', 'like', '%' . self::escapeLike($searchWord) . '%');
        }
        //カテゴリが選択された場合、m_categoriesテーブルからcategory_idが一致する商品を$queryに代入
        if (isset($categoryId)) {
            $query->where('category_id', $categoryId);
        }

        //$queryをcategory_idの昇順に並び替えて$productsに代入
        $products = $query->orderBy('category_id', 'asc')->paginate(15);

        //m_categoriesテーブルからgetLists();関数でcategory_nameとidを取得する
        $category = new MCategory;
        $categories = $category->getLists();

        return view('searchproduct', [
            'products' => $products,
            'categories' => $categories,
            'searchWord' => $searchWord,
            'categoryId' => $categoryId
        ]);
    }

    //「\\」「%」「_」などの記号を文字としてエスケープさせる
    public static function escapeLike($str)
    {
        return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $str);
    }
}
```

```searchProduct.blade.php
<main>
  <div class="container">
    <div class="mx-auto">
      <br>
      <h2 class="text-center">商品検索画面</h2>
      <br>
      <!--検索フォーム-->
      <div class="row">
        <div class="col-sm">
          <form method="GET" action="{{ route('searchproduct')}}">
            <div class="form-group row">
              <label class="col-sm-2 col-form-label">商品名</label>
              <!--入力-->
              <div class="col-sm-5">
                <input type="text" class="form-control" name="searchWord" value="{{ $searchWord }}">
              </div>
              <div class="col-sm-auto">
                <button type="submit" class="btn btn-primary ">検索</button>
              </div>
            </div>     
            <!--プルダウンカテゴリ選択-->
            <div class="form-group row">
              <label class="col-sm-2">商品カテゴリ</label>
              <div class="col-sm-3">
                <select name="categoryId" class="form-control" value="{{ $categoryId }}">
                  <option value="">未選択</option>

                  @foreach($categories as $id => $category_name)
                  <option value="{{ $id }}">
                    {{ $category_name }}
                  </option>  
                  @endforeach
                </select>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

    //以下追記
    <!--検索結果テーブル 検索された時のみ表示する-->
    @if (!empty($products))
    <div class="productTable">
      <p>全{{ $products->count() }}件</p>
      <table class="table table-hover">
        <thead style="background-color: #ffd900">
          <tr>
            <th style="width:50%">商品名</th>
            <th>商品カテゴリ</th>
            <th>価格</th>
            <th></th>
          </tr>
        </thead>
        @foreach($products as $product)
        <tr>
          <td>{{ $product->product_name }}</td>
          <td>{{ $product->category->category_name }}</td>
          <td>{{ $product->price }}円</td>
          <td><a href="#" class="btn btn-primary btn-sm">商品詳細</a></td>
        </tr>
        @endforeach   
      </table>
    </div>
    <!--テーブルここまで-->
    <!--ページネーション-->
    <div class="d-flex justify-content-center">
      {{-- appendsでカテゴリを選択したまま遷移 --}}
      {{ $products->appends(request()->input())->links() }}
    </div>
    <!--ページネーションここまで-->
    @endif
  </div>
</main>
```

これで検索機能は実装できました。何も入力せずに検索ボタンを押すと、以下の画面に遷移するはずです。

~/searchproduct?searchWord=&categoryId=

![スクリーンショット 2020-12-29 14.50.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/dc6d6300-8f26-98ab-71c6-821394d42ba6.png)

#各機能解説
これより各機能の解説をします。追記する部分はありません。

###Requestを使う

今回はユーザーから入力された商品名やカテゴリを、GETメソッドで値の受渡しを行うため、request機能を使っています。
そのため、コントローラー内で
`use Illuminate\Http\Request;`
を宣言し、以下のように各メソッドの引数にRequest $requestを指定します。
`public function search(Request $request)`

##検索機能

まずは商品名フォームに入力される値と、カテゴリが選択された時の値を指定します。view側では**name属性をそれぞれ指定することで、コントローラーとの受け渡しが可能になります。**

```ProductController.php
$searchWord = $request->input('searchWord'); //商品名の値
$categoryId = $request->input('categoryId'); //カテゴリの値
```

```searchProduct.blade.php
<!--商品名入力　nameに"searchWord"を指定-->
<div class="col-sm-5">
  <input type="text" class="form-control" name="searchWord" value="{{ $searchWord }}">
</div>

<!--カテゴリ選択　nameに"categoryId"を指定-->
<select name="categoryId" class="form-control" value="{{ $categoryId }}">
```

###クエリビルダでif文を作る
#####商品名を入力して検索
まず`$query = MProduct::query();`で、m_productsテーブルの中身を`$query`に代入します。
そして、商品入力フォーム($searchWord)に入力された時に実行するif文をクエリビルダで記述します。

今回の例では、**m_productsテーブルの商品名(product_name)から、一文字でも一致したら表示する曖昧検索を実行しています。**[参考][link-3] 
[link-3]:https://qiita.com/tatsuya4150/items/69c2c9d318e5b93e6ccd

```ProductController.php
$query = MProduct::query();
//商品名が入力された場合、m_productsテーブルから一致する商品を$queryに代入
if (isset($searchWord)) {
$query->where('product_name', 'like', '%' . self::escapeLike($searchWord) . '%');
}
```

#####カテゴリ選択

続いて、カテゴリ選択がされた場合の処理も記述します。
これも商品名を入力した場合の処理と同様に、**m_productsテーブルのcategory_idと、選択されたカテゴリ($categoryId)が一致する商品を表示します。**今回は肉類or魚介類ですね。

```ProductController.php
//カテゴリが選択された場合、m_categoriesテーブルからcategory_idが一致する商品を$queryに代入
if (isset($categoryId)) {
    $query->where('category_id', $categoryId);
}
```

#####表示順を並び替えて$productsに代入

カテゴリidの昇順(asc)に表示されるようにして、$productsに代入します。さらに商品が15件以上あった場合、ページネーションを表示するようにします。

```ProductController.php
//$queryをcategory_idの昇順に並び替えて$productsに代入
$products = $query->orderBy('category_id', 'asc')->paginate(15);
```

######カテゴリ選択機能(プルダウン)を作る
カテゴリ選択機能は、MCategoryモデルにgetLists()という関数を作ってプルダウンの中身を表示するようにしています。
処理内容として、次のようなことを行っています。

**①m_categoriesテーブルから、 category_nameとidをpluckで取り出し、$categoriesに返す**

```MCategory.php
public function getLists()
{
    $categories = MCategory::pluck('category_name', 'id');
    return $categories;
}
```
**②↑で定義したgetLists()関数をコントローラーで呼び出す**

```ProductController.php
$category = new MCategory;
$categories = $category->getLists();
```

######foreachでカテゴリを表示

続いて、viewファイルのプルダウンの記述を見てみましょう。`foreach`で全てのカテゴリを表示するようにします。
さらに、`@foreach($categories as $id => $category_name)`とすることで、オプションにカテゴリのidを与え、カテゴリ名を表示しています。

```searchProduct.blade.php
<select  name="categoryId" class="form-control" value="{{ $categoryId }}">
  <option value="">未選択</option>
    @foreach($categories as $id => $category_name)
      <option value="{{ $id }}">
        {{ $category_name }}
      </option>  
    @endforeach
</select>
```
こうすれば、データベースに後から野菜類などのカテゴリを追加しても、自動的に表示してくれます。

#####str_replaceでセキュリティ対策
商品名検索のif文に、`self::escapeLike($searchWord)`という記述があったと思いますが、これはセキュリティ対策のためです。
これを行わないと、**ユーザー情報漏洩などの割と致命的な脆弱性を抱えることになるので、必ず行いましょう。**
以下では、「%」や「\」などの記号をエスケープする処理を行っています。
また、`self::`と記述することで、同じファイル内で関数を呼び出すことができます。

```ProductController.php
public static function escapeLike($str)
{
    return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $str);
}
```

##GETメソッドとルーティング

ここからは、viewファイルを中心に解説していきます。

検索フォームのformタグには
`<form method="GET" action="{{ route('searchproduct')}}">`
とありますが、これは**「検索ボタンがクリックされたとき、GETメソッドを実行し、コントローラーのsearchproductを呼び出す」**ことを意味しています。
urlをみていただくと分かりますが、初期表示では`show`、検索ボタンをクリックすると`searchproduct?searchWord=&categoryId=`となっているはずです。
*初期表示*
![スクリーンショット 2021-01-03 16.32.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/0ff1b30b-5733-9769-9223-805d0405eccd.png)
*クリック後*
![スクリーンショット 2021-01-03 16.32.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491613/7b05baa0-3eaa-058e-ada7-08b7cb47f307.png)

##商品一覧のテーブル
####検索が実行されたときにテーブルを表示する

テーブルを`@if (!empty($products))`で囲います。

####表示件数を表示
検索結果を表示したい場合、`count()`関数を用います。今回は該当する商品($products)の数を表示したいので、以下のように記述します。
`<p>全{{ $products->count() }}件</p>`

####テーブルをforeachで作成
表示したい情報は、**「商品名」「商品カテゴリ」「価格」「商品詳細(遷移画面は作りません)」**の4つです。
そのためforeachで以下のように記述しています。

```searchProduct.blade.php
@foreach($products as $product)
    <tr>
      <td>{{ $product->product_name }}</td>
      <td>{{ $product->category->category_name }}</td>
      <td>{{ $product->price }}円</td>
      <td><a href="#" class="btn btn-primary btn-sm">商品詳細</a></td>
    </tr>
@endforeach
```
ポイントは商品カテゴリの`<td>{{ $product->category->category_name }}</td>`です。

コントローラーで

```ProductController.php
$category = new MCategory;
$categories = $category->getLists();
```
を宣言し、かつモデルでリレーション関係を作ることで、カテゴリ名を表示しています。

####ページネーション作成
ページネーションは`links()`関数を用いることで簡単に実装できます。
カテゴリを選択したままページ遷移をできるようにしたいので、`->appends(request()->input())`を記述しています。

※商品は5つしか作っていないので、ページネーションの数を2などにして動作確認をしてみてください。

```searchProduct.blade.php
<div class="d-flex justify-content-center">
  <!-- appendsでカテゴリを選択したまま遷移 -->
  {{ $products->appends(request()->input())->links() }}
</div>
```

#終わりに
ここまでご覧いただき誠にありがとうございました。これを応用してさらに絞り込み機能を増やすなど、ご自身の用途に合わせて実装してみてください。
筆者自身、いろんな人の手を借りて一ヶ月かけて実装したので、誰かの役に立てば幸いです。