10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-01-30

環境:XAMPP for Windows, Laravel8, Tailwind CSS

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

完成画面は以下のような感じ。リスト検索が出来て、検索して絞り込まれたリストのCSVダウンロードが出来ます。
image.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』に新規ファイルを作ります。ファイルに以下の通り追記して、インポートします。最後の4行は、ご自身で必要な分をインポートしてください。
(※ デフォルトで作られている『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ファイルでデータベース名を変更しておきます。

.env
DB_DATABASE=address_book

#4: 各ファイルの作成

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

php artisan make:model Address -mc

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

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

#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

#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);で中身を確認してみると、以下のように表示されます。

これは、Requestクラスのインスタンス自体(クラス本体)の情報が表示されています。

それでは、dd($request->all());とすると、
image.png
と表示されます。(検索窓口を一つにして、inputタグのname属性をkeywordにしている場合です。村山という名前で検索しています。)これは、データを配列で返すメソッド all() を呼んでいるため、データだけを返しています。(尚、toArray() も同じです。)

更に、dd($request->keyword);とすると、
image.png
と表示されます。
これは、PHPのマジックメソッド __get() { ... } がクラス内に定義されているためで、これは、「データ取得の際に該当するものが見つからなかったら呼ばれる」という特殊なメソッドになります。
そのため、流れとしては以下のようになります。
1.$request->keyword でデータを取得しようとする
2.クラス内に keyword に該当するものが見つからない
3.代わりに __get() { ... } を実行
4.その中身は以下のように定義されているので、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>

以上で全行程終了です!お疲れさまでした。ヽ(*´з`*)ノ パチパチ~!

10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?