この連載記事では、Laravel を使用した Web アプリケーションの開発方法を紹介します。実際に(お決まりの?)ToDo アプリを開発する手順を通して Web 開発のエッセンスを学んでいただけるように書いていきます。取り扱う Laravel のバージョンは現時点で最新の 5.7 です。
第10章ではエラーハンドリングと題して、アプリが想定する通常の使用とは異なる URL をリクエストされてもクラッシュせずにユーザーに適切なフィードバックを返却する方法を説明します。
Web アプリケーションでは、ユーザーがリクエストする URL は決して制限できません。ページ上にリンクがなかったとしても、ユーザーはブラウザのアドレスバーで自由な URL を作り出してアプリケーションにリクエストを送信できるのです。
しかし、開発者としては想定したリクエストと違うからといってアプリケーションがクラッシュするに任せるわけにはいきません。例えば定義されていない URL にアクセスがあったとき、システムが例外を吐いて真っ白な画面になってしまってはユーザーは訳が分かりません。単に URL を間違えただけかもしれないのに「このアプリは壊れてる」と思われたら機会損失ですよね。代わりに「そのページは見つかりません」といった適切なフィードバックを返してあげるべきです。
アプリケーション開発は「普通に」使える実装でおしまいではありません。URL の構造やロジックから起こりうるエラーパターンまで想定して対処する必要があります。
存在しないコンテンツ
まずは存在しないコンテンツにアクセスされた場合を考えます。つまり例えば存在しないフォルダの ID を含むタスク一覧の URL にアクセスした場合などです。試しに /folders/999 などの URL でアクセスしてみてください。task
メソッドを呼び出せないエラー画面が表示するはずです。
これはアプリケーションがクラッシュしている状態です。しかしこの場合のユーザーへの適切なフィードバックは「アプリケーションが壊れました」ではなく「お探しのページは見つかりません」であるべきです。この所謂 404 ページを皆さんも見たことがあるのではないでしょうか。
レスポンスステータスコード
HTTP の世界では、リクエストに対するレスポンスにはステータス(状態)を表すコード番号を添えるという決まりがあります。リクエストを受けての処理が成功したのか、失敗したのか、失敗したのなら原因はクライアント側かサーバー側か、という情報を決められたステータスコードで表現します。どのようなステータスコードがあるのか、下のページを読んでみてください。
HTTP レスポンスステータスコード - HTTP | MDN
たくさんのステータスコードが定義されていますね。ただ実際アプリケーション開発でよく使うのは 200, 201, 302, 303, 401, 404, 403, 500 あたりでしょう。他に Laravel ではバリデーションエラーの場合のレスポンスコードとして 422 が使用されています。
レスポンスコードにはそれぞれ意味があるので、状況によって適切なコードを選択しましょう。
ここでは存在しないコンテンツへのアクセスということで 404 を返却します。
abort 関数
Laravel ではエラー系(400番台 / 500番台)のレスポンスを返却する一番手軽な方法は abort
関数を使うことです。タスクコントローラーの index
メソッドでフォルダデータを取得処理の下に以下のようにコードを追加してください。
public function index(Folder $folder)
{
// 略
// 選ばれたフォルダを取得する
$current_folder = Folder::find($id);
if (is_null($current_folder)) {
abort(404);
}
// 略
}
abort
関数が呼び出されると引数のレスポンスコードで、コードに対応するエラーページが返却されます。言語的には例外が投げられるので、以降の処理は実行されません。
ブラウザからもう一度存在しないフォルダ ID のタスク一覧画面にアクセスしましょう。
今度は Laravel デフォルトの 404 ページが表示されたはずです。
このページをオリジナルのページに変更する方法はこの章の最後で紹介します。ここでは上記のようにコントローラーメソッドで abort
関数を呼び出す方法の問題点を考えます。
タスクコントローラー全体をざっと見てみましょう。先ほどと同じくフォルダデータが取得できなかったら abort(404)
を呼ぶ、という処理が必要なメソッドはどれでしょうか。
index
、showCreateForm
、create
、showEditForm
、edit
、、、すべてですね!
もっと言えば、すべてのコントローラーメソッドで ①フォルダデータを取得する、②取得できなかったら abort(404)
を呼ぶ、という一連の処理を記述する必要がありそうです。要するにこのまま abort
での実装方法を進めるとコードの重複がたくさん発生するでしょう。
この重複を防いでコードを美しく保つ機能が Laravel には用意されています。
「ルートモデルバインディング」です。
ルートモデルバインディング
ルートモデルバインディングは、Web アプリケーションでありがちな処理をまとめてフレームワーク側で面倒を見てくれる機能です。一言で言うと、ルーティングで定義された URL から自動的にデータを取得し、モデルクラスインスタンスをコントローラーメソッドに渡してくれます。
Web アプリケーションではコンテンツを特定する ID を URL に含めて、コントローラー側でその ID に対応するデータを取得し、取得できなかったら 404 を返却する処理は、言語やフレームワークを問わず頻出パターンです。
// URL
/something/何かのID
// コントローラーメソッド
$something = Something::where('id', 何かのID)->first();
if (is_null($something)) {
abort(404);
}
ルートモデルバインディングを使えばこの一連のパターンをいちいち自分で書かずともフレームワークに任せることができます。
ルーティング
まずはタスク一覧のルート定義を編集します。
Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');
URL の {id}
を {folder}
に書き換えてください。
コントローラー
次にコントローラーメソッドを編集します。
public function index(Folder $folder)
{
// ユーザーのフォルダを取得する
$folders = Auth::user()->folders()->get();
// 選ばれたフォルダに紐づくタスクを取得する
$tasks = $folder->tasks()->get();
return view('tasks/index', [
'folders' => $folders,
'current_folder_id' => $folder->id,
'tasks' => $tasks,
]);
}
int
型の $id
を受け取るのではなく、Folder
クラスの $folder
を受け取るよう記述します。これだけで URL 中の ID に該当するフォルダデータがコントローラーメソッドに渡されます。そのためフォルダデータを取得していた記述と abort
していた記述は不要になります。あとは $current_folder
を $folder
という変数名に合わせて書き換えます。
Route::get('/folders/{folder}/tasks', ... );
public function index(Folder $folder)
Laravel は、ルーティング定義の URL の中括弧で囲まれたキーワード({folder}
)とコントローラーメソッドの仮引数名($folder
)が一致していて、かつ引数が型指定(Folder
)されていれば、URL の中括弧で囲まれた部分の値を ID とみなし、自動的に引数の型のモデルクラスインスタンスを作成します。
ルートとモデルを結びつける(バインディング)機能というわけです。
タスクの作成と編集
タスク作成と編集のルートにもモデルとのバインディング機能を適用しましょう。
Route::get('/folders/{folder}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
Route::post('/folders/{folder}/tasks/create', 'TaskController@create');
Route::get('/folders/{folder}/tasks/{task}/edit', 'TaskController@showEditForm')->name('tasks.edit');
Route::post('/folders/{folder}/tasks/{task}/edit', 'TaskController@edit');
タスク編集のルートにはタスクモデルもバインディングしています。
コントローラーメソッドもそれぞれ index
メソッドと同様に編集します。一つずつ説明するのは冗長かと思いますので、クラスの全文を載せます。
<?php
namespace App\Http\Controllers;
use App\Folder;
use App\Http\Requests\CreateTask;
use App\Http\Requests\EditTask;
use App\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TaskController extends Controller
{
/**
* タスク一覧
* @param Folder $folder
* @return \Illuminate\View\View
*/
public function index(Folder $folder)
{
// ユーザーのフォルダを取得する
$folders = Auth::user()->folders()->get();
// 選ばれたフォルダに紐づくタスクを取得する
$tasks = $folder->tasks()->get();
return view('tasks/index', [
'folders' => $folders,
'current_folder_id' => $folder->id,
'tasks' => $tasks,
]);
}
/**
* タスク作成フォーム
* @param Folder $folder
* @return \Illuminate\View\View
*/
public function showCreateForm(Folder $folder)
{
return view('tasks/create', [
'folder_id' => $folder->id,
]);
}
/**
* タスク作成
* @param Folder $folder
* @param CreateTask $request
* @return \Illuminate\Http\RedirectResponse
*/
public function create(Folder $folder, CreateTask $request)
{
$task = new Task();
$task->title = $request->title;
$task->due_date = $request->due_date;
$folder->tasks()->save($task);
return redirect()->route('tasks.index', [
'id' => $folder->id,
]);
}
/**
* タスク編集フォーム
* @param Folder $folder
* @param Task $task
* @return \Illuminate\View\View
*/
public function showEditForm(Folder $folder, Task $task)
{
return view('tasks/edit', [
'task' => $task,
]);
}
/**
* タスク編集
* @param Folder $folder
* @param Task $task
* @param EditTask $request
* @return \Illuminate\Http\RedirectResponse
*/
public function edit(Folder $folder, Task $task, EditTask $request)
{
$task->title = $request->title;
$task->status = $request->status;
$task->due_date = $request->due_date;
$task->save();
return redirect()->route('tasks.index', [
'id' => $task->folder_id,
]);
}
}
権限がないコンテンツ
続いて権限がないコンテンツへのアクセスへの対策を考えます。存在はするが自分のものではないフォルダの ID を含む URL にアクセスされた場合です。この場合は権限がないことを意味する 403 コードをレスポンスするのが適切でしょう。
abort 関数
まずは abort
関数による実現方法を紹介します。
public function index(Folder $folder)
{
if (Auth::user()->id !== $folder->user_id) {
abort(403);
}
// 以下略
}
例によってタスク一覧表示の処理で考えます。index
メソッドの冒頭に上記のコードを追加してください。ログインユーザーの ID とフォルダの user_id カラムの値を比較しています。一致しなければログインユーザーはそのフォルダとは紐づいていない、つまり閲覧する権限がないので abort(403)
を実行します。
ブラウザで確認してみましょう。存在はするが現在のログインユーザーとは紐づいていないフォルダの ID でタスク一覧にアクセスします。
先ほどの 404 とは違う 403 のエラーページが表示されましたね?
しかし、この abort(403)
を呼び出す処理もすべてのコントローラーメソッドに繰り返し同じく記述しなければいけません。その重複を排除するために、ポリシークラスを紹介します。
ポリシークラス
ポリシークラスは Laravel での認可(Authorization)処理を司ります。
認可というのは、ユーザーの持つ権限にしたがって特定の処理を許可するか判断することです。認証(Authentication)とは似て非なる概念ですね。
ポリシークラスはモデルクラスを元に認可処理を行います。
ポリシークラスを作成する
コマンドラインからポリシークラスを作成します。
$ php artisan make:policy FolderPolicy
雛形 app/Policies/FolderPolicy.php
を以下の内容で編集してください。
<?php
namespace App\Policies;
use App\Folder;
use App\User;
class FolderPolicy
{
/**
* フォルダの閲覧権限
* @param User $user
* @param Folder $folder
* @return bool
*/
public function view(User $user, Folder $folder)
{
return $user->id === $folder->user_id;
}
}
ポリシークラスでは認可処理を、真偽値を返すメソッドで表現します。FolderPolicy
クラスでは view
メソッドによって「ユーザーとフォルダが紐づいているときのみ許可する」という意味の認可処理が定義されています。何を許可するのかはここでは定義しません。
ポリシーとモデルを紐づける
作成したポリシーは AuthServiceProvider
に登録します。
まずは Folder
クラスと FolderPolicy
クラスをインポートします。
<?php
namespace App\Providers;
use App\Folder; // ★ 追加
use App\Policies\FolderPolicy; // ★ 追加
$policies
プロパティでモデルクラスとポリシークラスを紐づけます。
protected $policies = [
Folder::class => FolderPolicy::class,
];
Folder
モデルに対する処理への認可には FolderPolicy
ポリシーを使用する、という意味です。
ポリシーをミドルウェアを介して使用する
では作成したポリシーを使用しましょう。いくつかの方法がありますが、今回はミドルウェアから呼び出して使用する方法を紹介します。
まずはタスク一覧のルートにのみミドルウェアを適用します。
Route::group(['middleware' => 'can:view,folder'], function() {
Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');
});
can
という名前のミドルウェアは、引数(コロン以降の部分)から適切な認可処理を判定してコントローラーメソッド実行前に適用します。認可処理が true
を返せばそのまま後続処理に移り、false
を返せば処理を中断してコード 403 でレスポンスします。can
ミドルウェアの引数(view,folder
)はカンマ区切りになっていて、カンマの左側が認可処理の種類、右側がポリシーに渡すルートパラメーター(URL の変数部分)を示します。
ルートモデルバインディングによってルートパラメーターから対応するモデルクラスが割り出されます。モデルクラスが分かると AuthServiceProvider
に登録した内容から適用すべきポリシークラスを特定できます。さらに認可処理の種類はポリシークラスのメソッド名とみなされます。
つまり今回は view,folder
という引数から、Folder
モデル → FolderPolicy
ポリシーの view
メソッドが認可に使用されることになります。view
メソッドで定義された認可処理は「ユーザーとフォルダが紐づいているときのみ許可する」という内容でした。
結果としてタスク一覧にアクセスしたとき、ユーザーに対して、ルートモデルバインディングで取得できたモデルインスタンスへの上記の認可処理を実行します。
コード間の関連が複雑ですが、少ないコードの記述でコントローラーメソッド内で同じ処理を繰り返し記述せずに済む、便利な機能です。
では abort(403)
のコードは削除してもう一度ブラウザから動作を確かめてください。
ログインユーザーに紐づかないフォルダのタスク一覧ページへのアクセスに対して 403 ページがレスポンスされたでしょうか?
すべてのルートにポリシーを適用する
では、タスク一覧以外のルートにもポリシーを適用しましょう。
ルーティングの定義は以下の通りです。
<?php
Route::group(['middleware' => 'auth'], function() {
Route::get('/', 'HomeController@index')->name('home');
Route::get('/folders/create', 'FolderController@showCreateForm')->name('folders.create');
Route::post('/folders/create', 'FolderController@create');
Route::group(['middleware' => 'can:view,folder'], function() {
Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');
Route::get('/folders/{folder}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
Route::post('/folders/{folder}/tasks/create', 'TaskController@create');
Route::get('/folders/{folder}/tasks/{task}/edit', 'TaskController@showEditForm')->name('tasks.edit');
Route::post('/folders/{folder}/tasks/{task}/edit', 'TaskController@edit');
});
});
Auth::routes();
ルートグループはネストすることができるので、まず認証ミドルウェアを適用してから、必要なルートに対しては認可ミドルウェアを適用しています。
リレーションが存在しない
次にリレーションが存在しないパターンを考えましょう。タスク編集ルートの URL にはフォルダ ID および タスク ID が含まれていますが、このフォルダ ID とタスク ID がちぐはぐで紐づいていなかったらどうなるかということです。
いまのところ、フォルダが存在してそのフォルダとログインユーザーが紐づいてさえいれば処理を実行できます。つまりタスク ID が他者のものでも編集できてしまうということです。
これはかなり脆弱ですね!そこで処理を実行する前にフォルダとタスクの紐づきを確認して、紐づいていなければ 404 を返すことにします。
public function showEditForm(Folder $folder, Task $task)
{
if ($folder->id !== $task->folder_id) {
abort(404);
}
// 以下略
}
public function edit(Folder $folder, Task $task, EditTask $request)
{
if ($folder->id !== $task->folder_id) {
abort(404);
}
// 以下略
}
ここでは abort
関数を使用して実装します。ただしやはり重複はなんとかしたいですね。
以下のように、チェックの処理をメソッドに切り出しましょう。
public function showEditForm(Folder $folder, Task $task)
{
$this->checkRelation($folder, $task);
// 以下略
}
public function edit(Folder $folder, Task $task, EditTask $request)
{
$this->checkRelation($folder, $task);
// 以下略
}
private function checkRelation(Folder $folder, Task $task)
{
if ($folder->id !== $task->folder_id) {
abort(404);
}
}
これで意図しない URL でのアクセスにも対策が取れました。
エラー画面を作ろう
この章の最後に、オリジナルのエラー画面を作成する手順を紹介します。
エラー画面を作るのは非常に簡単で、resources/views
ディレクトリにさらに errors
ディレクトリを作成します。この errors
ディレクトリに レスポンスコード.blade.php
という名前でテンプレートを作成すれば、abort
関数などでエラー系のレスポンスが作成されるときに対応するファイル名のテンプレートが画面として使われます。
以下が作成例です。
404
resources/views/errors/404.blade.php
@extends('layout')
@section('content')
<div class="container">
<div class="row">
<div class="col col-md-offset-3 col-md-6">
<div class="text-center">
<p>お探しのページは見つかりませんでした。</p>
<a href="{{ route('home') }}" class="btn">
ホームへ戻る
</a>
</div>
</div>
</div>
</div>
@endsection
403
resources/views/errors/403.blade.php
@extends('layout')
@section('content')
<div class="container">
<div class="row">
<div class="col col-md-offset-3 col-md-6">
<div class="text-center">
<p>お探しのページにアクセスする権限がありません。</p>
<a href="{{ route('home') }}" class="btn">
ホームへ戻る
</a>
</div>
</div>
</div>
</div>
@endsection
500
サーバー側のエラーで正常なレスポンスを返せないことを表すのが 500 番です。システムエラー画面とも呼ばれます。400 や 403 と同じ要領で作成してみましょう。
第10章はこれでおしまいです。
ここまでのソースコードはリポジトリ(chapter10 ブランチ)を参照してください。
そしてここまでで ToDo アプリケーションの実装は完了しました
機能は多くないですが、しっかり動作する一人前のアプリではないでしょうか。
チュートリアルのラストを飾る11章では、作成したアプリケーションを Heroku というクラウドサービスを利用してインターネットに公開する方法を紹介します。
連載記事
- 入門Laravelチュートリアル (1) イントロダクション
- 入門Laravelチュートリアル (2) ToDoアプリケーションの設計
- 入門Laravelチュートリアル (3) ToDoアプリのフォルダ一覧表示機能を作る
- 入門Laravelチュートリアル (4) ToDoアプリのタスク一覧表示機能を作る
- 入門Laravelチュートリアル (5) ToDoアプリのフォルダ作成機能を作る
- 入門Laravelチュートリアル (6) ToDoアプリのタスク作成機能を作る
- 入門Laravelチュートリアル (7) ToDoアプリのタスク編集機能を作る
- 入門Laravelチュートリアル (8) ToDoアプリの認証機能を作る
- 入門Laravelチュートリアル (9) ToDoアプリの認証機能を作る パート2
- 入門Laravelチュートリアル (10) エラーハンドリング
- 入門Laravelチュートリアル (11) ToDoアプリをHerokuにデプロイする