Laravelチュートリアル - 汎用業務Webアプリを作る (3/4) 検索画面とページネーション

# はじめに

Django経験者が、既存DjangoアプリをLaravelで再現するチュートリアル形式の記事を書くことでLaravelを学びます。

題材とさせていただいたDjangoアプリはこちらです。

[Djangoチュートリアル - 汎用業務Webアプリを最速で作る - Qiita](https://qiita.com/okoppe8/items/54eb105c9c94c0960f14)

# チュートリアル全体の構成

1. [Laradockで環境を構築する](https://qiita.com/shonansurvivors/items/43b4480a84921d185e34)

2. [テーブルとCRUD画面を作る](https://qiita.com/shonansurvivors/items/561cfe9c2ae02da65bd4)

3. 検索画面とページネーションを作る（**本記事**）

4. 認証機能を作る（作成予定）

# ソースコード

https://github.com/shonansurvivors/Laravel-Simple-CRUD-Sample/tree/%232

# 環境

- macOS High Sierra 10.13.6
- php 7.2.16
- Laravel 5.5.45
- PostgreSQL 9.6.2

# 3.1. 一覧画面に検索機能を追加する 

現状の一覧画面には`items`テーブルのレコードが全て表示されています。

これに対して検索する（条件に応じて絞り込む）機能を追加します。

## 3.1.1. ビューの編集

まず、検索条件を入力する画面を作成します。

もともと前記事では、一覧画面の`検索`をクリックすると以下のモーダル画面が開くところまでは作成していました。

![laravel_search1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/f0638cff-cf2d-941e-fcd0-d072b3aeccaf.png)

ビューファイルのモーダル画面部分は以下の内容となっています。

```html:src/resources/views/items/index.blade.php
@extends('base')
@section('content')
  <div class="container">
    <div id="myModal" class="modal fade" tabindex="-1" role="dialog">
      <div class="modal-dialog" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title">検索条件</h5>
            <button type="button" class="close" data-dismiss="modal" aria-label="閉じる">
              <span aria-hidden="true">&times;</span>
            </button>
          </div>
          <form id="filter" method="get">
            <div class="modal-body">
              <!-- TODO:検索フォーム -->
            </div>
          </form>
          <div class="modal-footer">
            <a class="btn btn-outline-secondary" data-dismiss="modal">戻る</a>
            <button type="submit" class="btn btn-outline-secondary" form="filter">検索</button>
          </div>
        </div>
      </div>
    </div>
//略
  </div>
//略
```

上記のビューファイルの`<!-- TODO:検索フォーム -->`の部分に代わりに以下を記述します。

```html:src/resources/views/items/index.blade.php
//略
<form method="GET" action="{{ route('index') }}" id="myform">
  <div class="form-group">
    <label>名前</label>
    <input type="text" name="name" class="form-control">
  </div>
  <div class="form-group">
    <label for="sex">性別</label>
    <select id="sex" name="sex" class="form-control">
      <option value="">---------</option>
      <option value="1">男性</option>
      <option value="2">女性</option>
      <option value="3">指定無し</option>
    </select>
  </div>
  <div class="form-group">
    <label>備考</label>
    <textarea name="memo" class="form-control"></textarea>
  </div>
</form>
//略
```

これにより、検索機能のモーダル画面は以下のように表示されるようになりました。

![laravel_search2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/61102a86-8aba-5d85-bcae-ae33d93b6928.png)

実際に何か検索条件を入力して、`検索`をクリックするとブラウザのアドレスバーには以下の様に`?`以降に検索条件が表示されるようになります。

`http://localhost/?name=渋沢+栄一&sex=1&memo=医者`

以上で、検索画面のビューファイルの作成は完了です。

## 3.1.2. コントローラーの編集 〜 クエリビルダを使う 〜

次に、`items`テーブルのレコードを実際に絞り込む処理を`ItemController`に追加します。

現時点の`ItemCobtroller`での、一覧画面表示メソッドである`index`は以下のようになっていました。

```php:/src/app/Http/Controllers/ItemController.php
//略
class ItemController extends Controller
{
    /**
     * 一覧表示
     *
     * @param Request $request
     * @return Response
     */
    public function index(Request $request){
      $items = Item::orderBy('created_at', 'desc')->get();

      return view('items.index', [
        'items' => $items
      ]);
    }
//略
}
```

これを以下のように修正します。

なお、今回コントローラーに全ての処理を記述していますが、本記事では後ほどモデルに処理を記述するやり方に関しても説明します。

``````php:/src/app/Http/Controllers/ItemController.php
//略
class ItemController extends Controller
{
    /**
     * 一覧表示
     *
     * @param Request $request
     * @return Response
     */
    public function index(Request $request){

        $query = Item::query();

        if(isset($request->name)){
            $query->where('name', 'like', '%'.$request->name.'%');
        }

        if(isset($request->sex)){
            $query->where('sex', $request->sex);
        }

        if(isset($request->memo)){
            $query->where('memo', 'like', '%'.$request->memo.'%');
        }

        $items = $query->orderBy('created_at', 'desc')->get();

        return view('items.index', [
            'items' => $items
        ]);
    }
//略
}
```

- `$query = Item::query();`によって、`Item`モデルの**クエリビルダ**が`$query`に代入されます。

- `where()`はクエリビルダのメソッドのひとつです。
  - 第一引数にカラム名を入れます。
  - 第二引数には比較演算子を入れます。
  - 第三引数にはカラムに対して比較を行う値を入れます。

- 例えば`where('name', 'like', '%foo%')`は、SQL文での`WHERE name LIKE '%foo%'`と同じように、「`name`カラムに、fooという文字列を含むレコードに対して」という意味になります。

- `where('sex', $request->sex);`は、`where('sex', '=', $request->sex);`と同じです。`where()`メソッドでは、第二引数の比較演算子が`'='`の場合は、省略できるようになっています。

- 今回`ItemController`に追加したコードのように、クエリビルダに対して、`where()`といった「条件（制約）となるメソッド」を繰り返し実行し、最後に`get()`メソッドを実行することで結果を取得できます。

##3.1.3. 実際に検索画面を使ってみる

検索処理の作成が完了したので、実際に検索画面を使ってみます。

以下の例では、検索条件の名前欄に`津田`と入力して`検索`をクリックしています。

![laravel_search3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/336208f1-812d-c302-51de-e523151f6b6c.png)


すると、`items`テーブルの`name`カラムに`津田`を含むレコードだけが、一覧画面の明細に表示されるようになりました。

一方、アドレスバーを見てみると`http://localhost/?name=津田&sex=&memo=`と表示されています。

`?`のパラメーターが、`ItemContrroller`の`index`メソッドに渡されたことで、上記のように絞り込みが行われました。

#3.2. 検索機能にローカルスコープを利用する

ここまで検索画面を作るにあたり、コントローラーに検索処理の全てを記述しました。

しかし、一般にコントローラーは肥大化しないようにすることが望ましいと言われています。

コントローラーの肥大化を防ぐ方法のひとつとして、Laravelに用意されたローカルスコープという仕組みを使うことが考えられます。

ローカルスコープについて、Laravel公式ドキュメントでは以下のように説明されています。

> ローカルスコープによりアプリケーション全体で簡単に再利用可能な、一連の共通制約を定義できます。例えば、人気のある(popular)ユーザーを全員取得する必要が、しばしばあるとしましょう。スコープを定義するには、scopeを先頭につけた、Eloquentモデルのメソッドを定義します。
>スコープはいつもクエリビルダインスタンスを返します。
>[Laravel 5.5 Eloquent：利用の開始 - ローカルスコープ](https://readouble.com/laravel/5.5/ja/eloquent.html#local-scopes)より抜粋

今回`ItemController`に追加した各`where()`メソッドが、この汎用業務Webアプリの他の箇所でも今後使われるようになるかどうかはわかりませんが、練習の意味でもローカルスコープを使ってみることにします。

##3.2.1. モデルの編集 〜 ローカルスコープの作成 〜

まず、`Item`モデルに以下を記述します。

```php:src/app/Item.php
//略
class Item extends Model
{
//略    
    /**
     * 名前での絞り込み
     * 
     * @param Builder $query
     * @param string|null $name
     * @return Builder
     */
    public function scopeNameFilter($query, string $name = null){

        if(!$name){
            return $query;
        }

        return $query->where('name', 'like', '%'.$name.'%');
    }

    /**
     * 性別での絞り込み
     * 
     * @param Builder $query
     * @param string|null $sex
     * @return Builder
     */
    public function scopeSexFilter($query, string $sex = null){

        if(!$sex){
            return $query;
        }

        return $query->where('sex', $sex);
    }

    /**
     * 備考での絞り込み
     * 
     * @param Builder $query
     * @param string|null $memo
     * @return Builder
     */
    public function scopeMemoFilter($query, string $memo = null){

        if(!$memo){
            return $query;
        }

        return $query->where('memo', 'like', '%'.$memo.'%');
    }
}
```

- ローカルスコープを定義する場合、その名前の先頭には`scope`を付けます。

## 3.2.2. コントローラーの編集 〜 ローカルスコープの利用 〜

次に、`ItemController`での一覧画面表示メソッドである`index`を以下の通り変更します。

（コメントアウトした部分は本来残す必要は無いのですが、比較のために敢えて残しています）

``````php:/src/app/Http/Controllers/ItemController.php
//略
class ItemController extends Controller
{
    /**
     * 一覧表示
     *
     * @param Request $request
     * @return Response
     */
    public function index(Request $request){

        /* ローカルスコープを利用することとした為、コメントアウト
        $query = Item::query();

        if(isset($request->name)){
            $query->where('name', 'like', '%'.$request->name.'%');
        }

        if(isset($request->sex)){
            $query->where('sex', $request->sex);
        }

        if(isset($request->memo)){
            $query->where('memo', 'like', '%'.$request->memo.'%');
        }

        $items = $query->orderBy('created_at', 'desc')->get();
        */

        $items = Item::nameFilter($request->name)
            ->sexFilter($request->sex)
            ->memoFilter($request->memo)
            ->orderBy('created_at', 'desc')
            ->get();

        return view('items.index', [
            'items' => $items
        ]);
    }
```

-  ローカルスコープを使用する場合、例えばそれが`scopeNameFilter`という名前であった場合、名前の先頭に付けた`scope`は省略する必要があります。  

このようにローカルスコープを使っても、従来通りの検索機能が実現できます。

以上で、ローカルスコープを用いた`ItemController`の改善は完了です。

#3.3. ページネーションの作成

ここからは、一覧画面にページネーションの機能を追加します。

現状の一覧画面は、全ての明細が1画面に表示されています。（もし検索条件を入力しているのであれば、検索条件に一致する全ての明細）

例えば、以下は`Items`テーブルのレコードが全部で9件あり、特に検索条件を入力しなかった場合の一覧画面です。

9件全てが1ページに表示されていますね。

![laravel_paginate0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/53a4646b-2076-4f16-ab89-8e202f46cabb.png)

これに対して、一定の件数ごとにページを切り替えて表示する、ページネーション機能を組み込みます。

##3.3.1. コントローラーの編集 〜 `paginate()`の利用 〜

まず、`ItemController`における一覧画面表示メソッドである`index`を変更します。

これまではクエリビルダの最後で`get()`にて`items`レコードを取得していましたが、ここを代わりに`paginate()`に変更します。

```php:/src/app/Http/Controllers/ItemController.php
//略
class ItemController extends Controller
{
    /**
     * 一覧表示
     *
     * @param Request $request
     * @return Response
     */
    public function index(Request $request){
//略
        $items = Item::nameFilter($request->name)
            ->sexFilter($request->sex)
            ->memoFilter($request->memo)
            ->orderBy('created_at', 'desc')
            ->paginate(3); //get()であったのを変更

        return view('items.index', [
            'items' => $items
        ]);
    }
//略
}
```

- `paginate()`には引数として、ひとつのページに表示する明細数を渡します。今回は`3`にしておきます。

##3.3.２. ビューの編集 〜 `links()`の利用 〜

次に、一覧画面のビューに各ページへのリンクを追加します。

現時点で、一覧画面のビューファイルで以下のようになっている箇所があるかと思います。

```html:src/resources/views/items/index.blade.php
//略
    <div class="row" >
      <div class="col-12">
        <!-- TODO:ページネーション -->
      </div>
    </div>
//略
```

こちらを以下の通り変更します。

```html:src/resources/views/items/index.blade.php
    <div class="row" >
      <div class="col-12">
        {{ $items->links() }}
      </div>
    </div>
```

以上で、ページネーション機能の組み込みは完了です。

##3.3.3. 実際にページネーション機能を使ってみる

実際にページネーション機能を使ってみます。

一覧画面にアクセスすると、左上に各ページへのリンクが表示されるようになりました。

また、`ItemController`の`index`メソッドで`paginage(3)`と指定したので、一覧画面には明細が3件までしか表示されないようになっています。

![laravel_paginate1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/e664387e-be55-874d-fc11-147dcbdc1e95.png)

`2`をクリックします。
すると、以下の通り2ページ目が表示されます。

![laravel_paginate2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/4a567ac5-89ee-abf3-4808-9bb939b8a3de.png)

ここで、アドレスバーを見ると、`http://localhost/?page=2`となっています。

`?`以降の、`page`というパラメーターに`2`を与えることによって、Laravel側で2ページ目を表示するように処理してくれていることがわかります。

##3.3.4. ページネーションをBootstrap4標準のデザインに変更する

Laravel5.5では、ページネーションの表示は簡素なものとなっています。

これをBootstrap4標準のデザインに変更します。

`bootstrap`ディレクトリにある、`app.php`の`return $app;`よりも手前で以下を追加してください。

```php:src/bootstrap/app.php
//略
Illuminate\Pagination\AbstractPaginator::defaultView("pagination::bootstrap-4"); // この行を追加
//略
return $app;

```

以上により、ページネーションがBootstrap4標準のデザインで表示されるようになりました。

![laravel_paginate3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/818dd8ec-7229-ae3d-c930-d8a63c2120a5.png)

#3.4. 検索条件と組み合わせた時のページネーションの不具合を改善する

##3.4.1. 現状のページネーションの問題点

ここまでで検索画面とページネーションを作成しましたが、この2つを組み合わせると想定外の表示になるケースがあります。

例えば、以下は検索条件の`性別`に`男性`を指定した時の、一覧画面1ページ目です。

アドレスバーは、`http://localhost/?name=&sex=1&memo=`となっており、`sex=1`（1は男性)の条件に従って、男性だけが表示されています。

ここまでは特に問題ありません。

![laravel_paginate4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/d5c0e960-450f-495c-4f14-b1b89b4219f8.png)

ただし、この画面のページネーションの2ページ目へのリンクのURLは`http://localhost/?page=2`となっており、検索条件に入力した条件(`page`以外のパラメーター)が考慮されていません。

ですので、2ページ目を表示すると、以下のように検索条件を一切無視して、性別が女性である明細も表示されてしまいます。

![laravel_paginate5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/6bb46254-b221-2836-293a-37f6d9fad078.png)

# 3.4.2. ページネーションに検索条件（リクエストのパラメーター）を引き継ぐ  〜 `appends()`と`all()`の利用 〜

この問題点を改善するため、`ItemController`の一覧画面表示メソッドである`index`を以下の通り変更します。

```php:/src/app/Http/Controllers/ItemController.php
//略
class ItemController extends Controller
{
    /**
     * 一覧表示
     *
     * @param Request $request
     * @return Response
     */
    public function index(Request $request){
//略
        $items = Item::nameFilter($request->name)
            ->sexFilter($request->sex)
            ->memoFilter($request->memo)
            ->orderBy('created_at', 'desc')
            ->paginate(3)
            ->appends($request->all()); // この行を追加

        return view('items.index', [
            'items' => $items
        ]);
    }
//略
}
```

このように`paginate()`の後に、`->appends($request->all())`を追加するだけで、ページネーションに検索条件(リクエストのパラメーター）が引き継がれるようになります。

先ほどの問題点の例であれば`http://localhost/?page=2`であった2ページ目へのリンクは、`http://localhost/?sex=1&page=2`となります。

なぜ、このように改善されたのか、少し長くなりますが順に説明していきます。

- クエリビルダに`pagenate()`メソッドを使うことで、`$items`には`Illuminate\Pagination\LengthAwarePaginator`オブジェクトが返されています。

- この`LengthAwarePaginator`オブジェクトは様々なプロパティを持っており、そのうちのひとつに`query`があります。

- 今回の`ItemController`への処理の改善を行う前、つまり`appends($request->all())`を行わなかった場合、`query`は常に空の配列になっています。

```:$itemsをprint_rで表示したもの
Illuminate\Pagination\LengthAwarePaginator Object
(
    [total:protected] => 9
//略
    [perPage:protected] => 3
    [currentPage:protected] => 1
    [path:protected] => http://localhost
    [query:protected] => Array
        (
        )
//略
```

- `appends()`メソッドは、上記の`query`に値をセットしてくれます。

- `appends($request->all())`とすることにより、検索条件を入力して最初に`検索`をクリックした時のリクエストの全てのパラメータ(つまり検索条件)が、下記のように`query`へ連想配列でセットされます。

```:$itemsをprint_rで表示したもの
Illuminate\Pagination\LengthAwarePaginator Object
(
    [perPage:protected] => 3
    [currentPage:protected] => 1
    [path:protected] => http://localhost
    [query:protected] => Array
        (
            [name] => 
            [sex] => 1
            [memo] => 
        )
```

- ページネーションに表示される各ページへのリンクは、上記の`query`が考慮されるので、例えば2ページ目へのリンクであれば`http://localhost/?sex=1&page=2`となります。

##3.4.3. 改善後のページネーション機能を使ってみる

改善後のページネーション機能を使ってみます。

一覧画面の2ページ目でも、最初に入力した検索条件が考慮されるようになりました。

![laravel_paginate6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317369/4aa70dd0-4e33-8f0a-b5d5-b3a4fbf2257d.png)


# 最後に

以上で検索画面とページネーションの作成は完了です。

次の記事では、認証機能を追加する予定です。

# 参考

- [Laravel5.5でお手軽にフィルタ&検索付きメモアプリを作るチュートリアル - Qiita](https://qiita.com/namaozi/items/11b65ccb6b7ecaefc23e)

- [Laravelのクエリビルダ記法まとめ（QueryBuilder/DB Facade） - 
 RitoLabo](https://www.ritolab.com/entry/93)

- [【Laravel】ローカルスコープについて解説 - とものブログ](https://se-tomo.com/2018/10/12/laravel%E3%81%AF%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97%E3%81%A7%E7%B5%9E%E3%82%8A%E8%BE%BC%E3%82%82%E3%81%86/)

- [[Laravel 5.5] ページネーションを Bootstrap 4 スタイルにする - Qiita ](https://qiita.com/kamikosi/items/d56e6be42608aeafdb6c)

- [Laravelでページネーションを実装する方法 - 実践的Web開発メソッド](https://blog.hiroyuki90.com/articles/laravel-pagination/)

- [Laravelのページネーションで生成されたリンクにGETパラメータを付与する - Qiita](https://qiita.com/tech31/items/5b14aab55e3438ad3dde)