環境:XAMPP for Windows, Laravel8, Tailwind CSS
『PHPで作られるサービスにはCSVをダウンロードさせる機能が多いから、PHPを扱うエンジニアであればCSVを出力させる機能は作れて当たり前』というネット記事を先日読みました。『えぇ!!そうなの!?』ってことで、早速CSVダウンロード機能を作ってみようと思います!
簡単な住所録アプリを作る体で、チュートリアル形式で書いていこうと思います。まだCSV出力の機能を実装された経験のないPHPエンジニアさんは、是非参考になさってください!
完成画面は以下のような感じ。リスト検索が出来て、検索して絞り込まれたリストのCSVダウンロードが出来ます。
全体のソースコードは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ファイルを以下の通りに編集します。
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』は使用しません。)
@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ファイルでデータベース名を変更しておきます。
DB_DATABASE=address_book
#4: 各ファイルの作成
以下コマンドを打って、『Address』という名前でモデルを作成します。-mcオプションでマイグレーションファイルとコントローラーもついでに作成します。
尚、今回作るテーブル(マイグレーションファイル)は、住所録となるもの一つだけです。
php artisan make:model Address -mc
今回の住所録には、『名前』『郵便番号』『県』『市』『町名以降の番地』『電話番号』の6つを挿入します。
(あくまで目的はCSV出力機能の学習だから、ここは簡素にするわよ。。。ww( ´艸`))
なので、マイグレーションファイルは以下のように編集します。
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
#5: ダミーデータの作成
addressesテーブルにダミーデータを入れていきます。
以下コマンドを打って、Fakerを使う為のファイルを作成します。
php artisan make:factory AddressFactory --model=Address
\address-book\database\factories にAddressFactory.phpというファイルができますので、以下の通りreturn部分を編集します。
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個追加する場合。)
public function run()
{
\App\Models\Address::factory(50)->create(); //←追記
}
\address-book\config\app.php ファイルの'faker_locale'を以下のように修正します。
'faker_locale' => 'ja_JP',
ターミナルで以下コマンドを実行します。
php artisan db:seed
#6: モデルの編集
今回は、絞り込み検索機能のコードを、(しかもちょっと長めのコード。)コントローラーで二箇所にて使います。その為、Laravelのローカルスコープを使って、モデルファイルに一つにまとめたいと思います!
Laravelのローカルスコープについては、分かりやすい記事が結構出てるので、ご存知でない方は是非調べてみてください!
\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ダウンロードボタンを押したときにヒットする関数です。
全体のソースコードは以下の通りです。
<?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());とすると、
と表示されます。(検索窓口を一つにして、inputタグのname属性をkeywordにしている場合です。村山という名前で検索しています。)これは、データを配列で返すメソッド all() を呼んでいるため、データだけを返しています。(尚、toArray() も同じです。)
更に、dd($request->keyword);とすると、
と表示されます。
これは、PHPのマジックメソッド __get() { ... } がクラス内に定義されているためで、これは、「データ取得の際に該当するものが見つからなかったら呼ばれる」という特殊なメソッドになります。
そのため、流れとしては以下のようになります。
1.$request->keyword でデータを取得しようとする
2.クラス内に keyword に該当するものが見つからない
3.代わりに __get() { ... } を実行
4.その中身は以下のように定義されているので、keyword の値が取得できる
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つのルーティングを設定します。コードは以下の通りです。
Route::get('/', 'App\Http\Controllers\AddressController@index');
Route::get('/export', 'App\Http\Controllers\AddressController@csvDownload')->name('export');
2つ目のルーティングには->name()で名前をつけておきます。
#9: viewファイルの編集
viewファイルは一つだけ作ります。コードはこちら!
<!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>
以上で全行程終了です!お疲れさまでした。ヽ(*´з`*)ノ パチパチ~!