［Laravel］住所録アプリで作るCSVダウンロード機能作成のチュートリアル

環境：XAMPP for Windows, Laravel8, Tailwind CSS

『PHPで作られるサービスにはCSVをダウンロードさせる機能が多いから、PHPを扱うエンジニアであればCSVを出力させる機能は作れて当たり前』というネット記事を先日読みました。『えぇ！！そうなの！？』ってことで、早速CSVダウンロード機能を作ってみようと思います！
簡単な住所録アプリを作る体で、チュートリアル形式で書いていこうと思います。まだCSV出力の機能を実装された経験のないPHPエンジニアさんは、是非参考になさってください！

完成画面は以下のような感じ。リスト検索が出来て、検索して絞り込まれたリストのCSVダウンロードが出来ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/38531c6b-0225-1409-2bb9-1fad2004b1f1.png)

全体のソースコードはGitHubにアップしておりますので、必要な方は以下のリンクよりご確認ください。
https://github.com/Tomochan-taco/address-book


それでは早速スタート！

#1： プロジェクトの作成

composerでプロジェクトを作成します。
プロジェクト名は『address-book』とします。

```
composer create-project --prefer-dist laravel/laravel address-book
```

因みに、今回のLaravelのバージョンは以下の通りとなりました！

```
php artisan --version //Laravelバージョン確認用コマンド
Laravel Framework 8.25.0
```

#2： Jetstreamとか諸々のインストール

Tailwind CSSとか、FontAwesomeとかを使いたいので、cdコマンドでプロジェクト内に移動したら、Jetstreamをインストールします。

```
composer require laravel/jetstream
```

Livewireをインストールします。

```
php artisan jetstream:install livewire
```

ビルドします。

```
npm install && npm run dev
```

次に、FontAwesomeをインストールしたいので、webpack.mix.jsファイルを以下の通りに編集します。

```webpack.mix.js
mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .options({
        postCss: [
            require('postcss-import'),
            require('tailwindcss'),
        ]
    });
```

ターミナルで以下の通りコマンドを打って、Font Awesomeをインストールします。（以下は無料バージョンの場合です。）

```
npm install --save @fortawesome/fontawesome-free
```

『resources\sass\app.scss』に新規ファイルを作ります。ファイルに以下の通り追記して、インポートします。最後の４行は、ご自身で必要な分をインポートしてください。
（※ デフォルトで作られている『resources\css\app.css』は使用しません。）

```resources\sass\app.scss
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/brands';
```

もう一回ビルドします。

```
npm install && npm run dev
```

#3： データベースとテーブルの作成

データベース名は『address_book』としてサクッとXAMPP側で作っておきます。
.envファイルでデータベース名を変更しておきます。

```php:.env
DB_DATABASE=address_book
```
#4： 各ファイルの作成

以下コマンドを打って、『Address』という名前でモデルを作成します。-mcオプションでマイグレーションファイルとコントローラーもついでに作成します。
尚、今回作るテーブル（マイグレーションファイル）は、住所録となるもの一つだけです。

```
php artisan make:model Address -mc
```

今回の住所録には、『名前』『郵便番号』『県』『市』『町名以降の番地』『電話番号』の6つを挿入します。
（あくまで目的はCSV出力機能の学習だから、ここは簡素にするわよ。。。ｗｗ( ´艸｀)）
なので、マイグレーションファイルは以下のように編集します。

```php:2020_11_02_071333_create_addresses_table.php
    public function up()
    {
        Schema::create('addresses', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('zip_code');
            $table->string('prefecture');
            $table->string('city');
            $table->string('address');
            $table->string('phone_number');
            $table->timestamps();
        });
    }
```

マイグレーションします。

```
php artisan migrate
```

以下画像のように、テーブルが7つ出来ています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/64345b9c-5c08-17ff-6e9e-bd6dc482be55.png)

#5： ダミーデータの作成

addressesテーブルにダミーデータを入れていきます。
以下コマンドを打って、Fakerを使う為のファイルを作成します。

```
php artisan make:factory AddressFactory --model=Address
```

\address-book\database\factories にAddressFactory.phpというファイルができますので、以下の通りreturn部分を編集します。

```\address-book\database\factories\AddressFactory.php
    public function definition()
    {
        return [
            'name' => $this->faker->name,
            'zip_code' => $this->faker->postcode,
            'prefecture' =>$this->faker->prefecture,
            'city' =>$this->faker->city,
            'address' => $this->faker->streetAddress,
            'phone_number' => $this->faker->phoneNumber
        ];
    }
```

\address-book\database\seeders\DatabaseSeeder.php ファイルのrun()メソッド部分を、以下のように編集します。(50個追加する場合。)

```\address-book\database\seeders\DatabaseSeeder.php
    public function run()
    {
        \App\Models\Address::factory(50)->create(); //←追記
    }
```
\address-book\config\app.php ファイルの'faker_locale'を以下のように修正します。

```\address-book\config\app.php
'faker_locale' => 'ja_JP', 
```

ターミナルで以下コマンドを実行します。

```
php artisan db:seed
```

以下画像の通りデータが入りました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/7991e3ba-b432-2a1c-e159-44967bb1590f.png)

#6： モデルの編集

今回は、絞り込み検索機能のコードを、（しかもちょっと長めのコード。）コントローラーで二箇所にて使います。その為、Laravelのローカルスコープを使って、モデルファイルに一つにまとめたいと思います！
Laravelのローカルスコープについては、分かりやすい記事が結構出てるので、ご存知でない方は是非調べてみてください！
\address-book\app\Models\Address.phpファイルを以下の通り編集します。

```\address-book\app\Models\Address.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Address extends Model
{
    use HasFactory;

    public function scopeSearch($query) {

        $request = request();

        $query->when($request->name, function($q, $name) {
            $q->where('name', 'LIKE', '%' . $name . '%');
        })
        ->when($request->zip_code, function($q, $zip_code) {
            $q->where('zip_code', 'LIKE', '%' . $zip_code . '%');
        })
        ->when($request->prefecture, function($q, $prefecture) {
            $q->where('prefecture', 'LIKE', '%' . $prefecture . '%');
        })
        ->when($request->city, function($q, $city) {
            $q->where('city', 'LIKE', '%' . $city . '%');
        })
        ->when($request->address, function($q, $address) {
            $q->where('address', 'LIKE', '%' . $address . '%');
        })
        ->when($request->phone_number, function($q, $phone_number) {
            $q->where('phone_number', 'LIKE', '%' . $phone_number . '%');
        });

    }
}

```

簡単にコードの解説をします。
まず、\$request = request(); の部分で、フォームから渡ってきた値を取得しています。
その後は、when()関数を使って、各フォーム（検索窓口）に値があった場合のみ、where()関数を使って、文字列抽出するようにしています。
※ when()関数についてちょっと補足。
when()関数は、第一引数がtrueの時だけ、第二引数の無名関数が実行されます。その際、第二引数となる無名関数の引数には、一個目にクエリビルダのインスタンス、二個目には第一引数の値が入ります。
when()関数に関するドキュメントはこちら⇒
https://laravel.com/docs/8.x/queries#conditional-clauses

因みに。。。
検索窓口を複数に分けずに、一つにまとめる場合は、以下のように->orWhere()でつなげます。（inputタグのname属性をkeywordにしている場合です。）

```
    public function scopeSearch($query) {

        $request = request();

        $query->when($request->keyword, function($q, $keyword){

            $q->where('name', 'LIKE', '%'. $keyword .'%')　
                ->orWhere('zip_code', 'LIKE', '%'. $keyword .'%')
                ->orWhere('address', 'LIKE', '%'. $keyword .'%')
                ->orWhere('phone_number', '%'. $keyword .'%');

        });

    }
```

#7: コントローラーの編集

お次はコントローラーを編集します。作成する関数は、index()と、 csvDownload()です。
index()はトップページにアクセスした時、もしくは検索フォームで何か検索した時にヒットする関数です。
csvDownload()はその名の通り、CSVダウンロードボタンを押したときにヒットする関数です。
全体のソースコードは以下の通りです。

```\address-book\app\Http\Controllers\AddressController.php
<?php

namespace App\Http\Controllers;

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

class AddressController extends Controller
{
    public function index(Request $request) {
        $addresses = Address::search()->paginate(15);
        $search_params = $request->only([
            'name',
            'zip_code',
            'prefecture',
            'city',
            'address',
            'phone_number'
        ]);

        return view('index', [
            'addresses' => $addresses,
            'search_params' => $search_params
        ]);
    }

    public function csvDownload() {
        $addresses = Address::search()->get();

        $headers = [
            "Content-type" => "text/csv",
            "Content-Disposition" => "attachment; filename=file.csv"
        ];

        $callback = function() use($addresses) {
            $handle = fopen('php://output', 'w');
            
            $columns = [
                'id',
                'name',
                'zip_code',
                'prefecture',
                'city',
                'address',
                'phone_number'
            ];

            mb_convert_variables('SJIS-win', 'UTF-8', $columns);

            fputcsv($handle, $columns);

            foreach($addresses as $address) {
                $csv = [
                    $address->id,
                    $address->name,
                    $address->zip_code,
                    $address->prefecture,
                    $address->city,
                    $address->address,
                    $address->phone_number
                ];

                mb_convert_variables('SJIS-win', 'UTF-8', $csv);

                fputcsv($handle, $csv);
            }

            fclose($handle);
        };

        return response()->stream($callback, 200, $headers);

    }
}
```

コードの解説をします。
まずは、index()関数ですが、検索した時にフォームから渡ってくるのはRequest型なので、引数には(Request \$request)を取ります。
『\$addresses = Address::search()->paginate(15);』の箇所で、先程作ったモデルのローカルスコープから値を取ります。ページネーションを使うので、paginate()を使います。
その後、検索窓口が複数あるので、各フォームから渡ってくるパラメーターのキーの値を、コレクションクラスのonly()メソッドを使って取得します。
最後に、\$addressesと、\$search_paramsをviewに戻り値として返します。
因みに、検索窓口が一つの場合は、only()関数等は使わずに、下記のようにviewにそのままkeywordの値を送ります。

```
        return view('index', [
            'addresses' => $addresses,
            'keyword' => $request->keyword
        ]);
```

＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊
更におまけで、自分への備忘録としてちょこっと解説～。(\*´ω｀\*)
引数の(Request \$request)ですが、ここをdd($request);で中身を確認してみると、以下のように表示されます。
<img width="250px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/41436598-3dd1-f481-cdb3-a28ebbdf91c2.png">
これは、Requestクラスのインスタンス自体（クラス本体）の情報が表示されています。

それでは、dd(\$request->all());とすると、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/0052e2d9-42f5-bd16-b839-2fa287f6702b.png)
と表示されます。（検索窓口を一つにして、inputタグのname属性をkeywordにしている場合です。村山という名前で検索しています。）これは、データを配列で返すメソッド all() を呼んでいるため、データだけを返しています。（尚、toArray() も同じです。）

更に、dd(\$request->keyword);とすると、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235741/e6aa4d52-4be7-094a-d68b-0c4ce8a177c7.png)
と表示されます。
これは、PHPのマジックメソッド __get() { ... } がクラス内に定義されているためで、これは、「データ取得の際に該当するものが見つからなかったら呼ばれる」という特殊なメソッドになります。
そのため、流れとしては以下のようになります。
１．\$request->keyword でデータを取得しようとする
２．クラス内に keyword に該当するものが見つからない
３．代わりに __get() { ... } を実行
４．その中身は以下のように定義されているので、keyword の値が取得できる

```src/Illuminate/Http/Request.php
public function __get($key)
{
    return Arr::get($this->all(), $key, function () use ($key) {
        return $this->route($key);
    });
}
```

マジックメソッドに関するマニュアルはこちら　⇒
https://www.php.net/manual/ja/language.oop5.magic.php
＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊＊

お次は、csvDownload()について解説します。
まず前提として、CSVダウンロード機能を作る際は、
① fopen()でファイルを開いて、
② fputcsv()でデータを書き込んで、（ヘッダ行と実データ部分の二つを書き込む。実データは、foreachでループさせる。）
③ fclose()でファイルを閉じる。
という一連の操作が基本らしいです。
参考にしたサイトはこちら↓↓
https://coinbaby8.com/php-csv-export.html
https://programmer-jobs.blogspot.com/2017/04/laravel-5-4-csv-download.html

それではコードの解説をしていきます。
まずは、『\$addresses = Address::search()->get();』の箇所で、index()関数の時と同様、モデルのローカルスコープから値を取ります。『\$headers = [ ~~ ];』の箇所では、この関数の一番最後で使う、response()ヘルパのstream()メソッドに渡す値を指定しています。
『\$callback = ~~ 』の箇所では、上記で解説した通り、fopen()、fputcsv()、fclose()の一連の流れを操作する箇所です。
『\$handle = fopen('php://output', 'w');』の箇所で、新規ファイルを開いて、『\$columns = [ ~~ ];』の箇所で、書き込みヘッダ行を指定します。
その後、『mb_convert_variables('SJIS-win', 'UTF-8', \$columns);』の箇所では、そのヘッダ行の文字コードをShift_JISに変換して、
『fputcsv(\$handle, \$columns);』の部分で、\$handleで指定したファイルに、ヘッダ行を書き込んでいます。
『foreach(\$addresses as \$address) ～～』の箇所では、モデルのローカルスコープで絞り込んだ実データをループで回しています。そして、それをヘッダ行と同じように、『mb_convert_variables('SJIS-win', 'UTF-8', \$csv);』と、『fputcsv($handle, \$csv);』の箇所で、文字コードの変換とファイルへの書き込みを行い、『fclose(\$handle);』でファイルを閉じます。
そして最後に『return response()->stream(\$callback, 200, \$headers);』の箇所で、response()ヘルパのstream()メソッドを使って、ファイルをダウンロードします。
コントローラーの解説は以上です。

#8: ルーティングの編集

viewファイルを作る前にルーティングの編集をしちゃいます。
閲覧するページ自体は1ページしかないので、トップ画面となるindexページと、ダウンロードボタンを押した時のデータを飛ばすページ（表現の仕方合ってるかな？ww）の、2つのルーティングを設定します。コードは以下の通りです。

```\address-book\routes\web.php
Route::get('/', 'App\Http\Controllers\AddressController@index');
Route::get('/export', 'App\Http\Controllers\AddressController@csvDownload')->name('export');
```

2つ目のルーティングには->name()で名前をつけておきます。

#9: viewファイルの編集

viewファイルは一つだけ作ります。コードはこちら！

```\address-book\resources\views\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>
  <link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>

<body>
<div class="container mx-auto p-5">
  <p class="text-3xl mb-8"><i class="far fa-address-book"></i> 住所録</p>

  <div class="grid grid-cols-5 gap-10">
    <div>
      <p class="text-xl mb-5"><i class="fas fa-search"></i> 検索</p>
      <form method="GET" action="/">
        <div class="mb-5">
          <label for="name" class="block mb-2 font-bold">名前：</label>
          <input type="text" name="name" id="name" class="shadow appearance-none border rounded w-full py-2 px-3">
        </div>

        <div class="mb-5">
          <label for="name" class="block mb-2 font-bold">郵便番号：</label>
          <input type="text" name="zip_code" id="name" class="shadow appearance-none border rounded w-full py-2 px-3">
        </div>

        <div class="mb-5">
          <label for="name" class="block mb-2 font-bold">住所：</label>
          <input type="text" name="prefecture" id="name" class="shadow appearance-none border rounded w-full py-2 px-3 mb-2" placeholder="都道府県">
          <input type="text" name="city" id="name" class="shadow appearance-none border rounded w-full py-2 px-3 mb-2" placeholder="市">
          <input type="text" name="address" id="name" class="shadow appearance-none border rounded w-full py-2 px-3" placeholder="町名・番地">
        </div>

        <div class="mb-8">
          <label for="name" class="block mb-2 font-bold">電話番号：</label>
          <input type="text" name="phone_number" id="name" class="shadow appearance-none border rounded w-full py-2 px-3">
        </div>

        <div class="flex justify-center">
          <button type="submit" class="hover:opacity-75 bg-blue-500 font-semibold text-white py-2 px-4 rounded">送信</button>
        </div>

      </form>
    </div>

    <div class="col-span-4">
      <table class="border-collapse border w-full table-auto">
        <thead class="bg-gray-100">
          <tr>
            <th class="border p-2 border-b-4">
              id
            </th>
            <th class="border p-2 border-b-4">
              名前
            </th>
            <th class="border border-b-4">
              郵便番号
            </th>
            <th class="border border-b-4">
              都道府県
            </th>
            <th class="border border-b-4">
              市
            </th>
            <th class="border border-b-4">
              町名・番地
            </th>
            <th class="border border-b-4">
              電話番号
            </th>
          </tr>
        </thead>
        <tbody>
        @foreach ($addresses as $address)
          <tr class="hover:bg-grey-lighter">
            <td class="border">
              {{$address->id}}
            </td>
            <td class="border">
              {{$address->name}}
            </td>
            <td class="border">
              {{$address->zip_code}}
            </td>
            <td class="border">
              {{$address->prefecture}}
            </td>
            <td class="border">
              {{$address->city}}
            </td>
            <td class="border">
              {{$address->address}}
            </td>
            <td class="border">
              {{$address->phone_number}}
            </td>
          </tr>
        @endforeach
        </tbody>
      </table>

      <div class="mt-5">
        {{ $addresses->appends(request()->except('page'))->links() }}
      </div>

      <div class="flex justify-center mt-10">
      <form method="GET" action="{{ route('export') }}">
        @foreach($search_params as $key => $value)
          <input type="hidden" name="{{ $key }}" value="{{ $value }}">
        @endforeach
        <button type="submit" class="hover:opacity-75 bg-blue-500 font-semibold text-white py-2 px-4 rounded">
          CSVダウンロード
        </button>
      </form>
      </div>

    </div>
  </div>
</div>

</body>

</html>
```

ポイントを2点解説します。
一つ目は、ページネーションの箇所です。（『{{ $addresses->appends(request()->except('page'))->links() }}』のところ。）
この書き方は、複数のパラメーターを保持する場合に使います。
因みに、パラメーターが一つだった場合は、以下のように書きます。（inputタグのname属性をkeywordにしている場合です。）

```
 {{ $addresses->appends(['keyword' => Request::get('keyword')])->links() }}
```

2つ目は、CSVダウンロードボタンの箇所です。（『\<form method="GET" action="{{ route('export') }}">　～～ 』のところ。）
これは、複数のパラメーターを保持するので、\<form\>を使って、そのパラメーターを送るようにしています。
因みに、パラメーターが一つだった場合は、以下のようにリンク形式にすることができます。（inputタグのname属性をkeywordにしている場合です。）

```
<a href="{{ route('export')}}?keyword={{ $keyword }}">CSVダウンロード</a>
```

以上で全行程終了です！お疲れさまでした。ヽ(\*´з｀\*)ﾉ ﾊﾟﾁﾊﾟﾁ～！