PHP
laravel
exception
laravel5.5

エラー画面やAPIエラーから独自エラーまで! フローチャートでちゃんと理解するLaravelの例外処理とケーススタディ

image.png

TL;DR

  • Laravel 5.5 ベース(Laravel 5.7 まで対応)
  • フローチャートでおおまかな処理の流れと、どこでどんなことをするのかを解説します
  • それを踏まえて「こんな時はこうする」というケーススタディを紹介
  • 中小規模のプロジェクトにはそのままコピペで使ってもらえるベストプラクティス的なものを目指しています
  • 実際にこれをベースにしたものが中規模業務アプリに実装されています

動機

  • 個人的にエラー処理の仕組みを理解するために書いたチャートです
  • 自分で勉強しようとしたとき、Laravelのエラー処理に関する記事はたくさんありますが、包括的に全部書いた記事が意外とないなーと思った
  • 無いなら書かないと……(穴埋め係根性)

1. Laravel エラーハンドリングの仕組み

全体マップ

appディレクトリにある app\Exceptions\Handler.php の親クラス Illuminate\Foundation\Exceptions\Handler.php と、その呼び出し元を含んだフロー図です。コンソールの分岐など、省略している部分もあります……。

image.png

Laravelのエラーハンドラは、 Illuminate\Foundation\Exceptions\Handler.php にほとんどの機能が集約されています。
呼び出し元は、大きく2箇所で、PHP全体のシャットダウン関数と、コントローラで発生したエラーを全部catchするパイプラインの入り口です。そのどちらもよく似た呼び出し方をしています。

大きな流れは下記のとおり。

  1. report する
  2. render レスポンスを生成するための条件分岐を定義する
    1. prepareException 例外クラスの型を変換する
    2. unauthenticated ログインエラーを処理する
    3. invalid/invalidJon バリデーションエラーを処理する
    4. prepareResponse それ以外のエラーのレスポンスを準備する
      1. renderHttpException デザインされたエラー画面を生成する
      2. convertExeptionToResponse Laravel標準のエラー画面を生成する
      3. prepareJsonResponse API用のJSON形式のエラーを生成する
  3. レスポンスを返す

さて次に、図の中で点線で囲ったところを拡大して、もう少し細かく見ていきましょう。

Pipeline@handlerException メソッド

エラーハンドラ Handler を呼び出しているところです。
「大きく2箇所ある」と書きましたが、

  • PHP全体のシャットダウン関数
  • コントローラで発生したエラーを全部catchするパイプラインの入り口

の2箇所です。どちらもだいたい同じようなフローで呼び出しを行っています。

image.png

流れは大きく、

  1. report する
  2. render して Response を得る
  3. Response を返す

というシンプルなものです。
report と render の順番と、report には返り値がない、ということが重要なポイント。
これを覚えておけば、report と render の違いを混同することは無い と思います。

下記が実際の呼び出しコード(をわかりやすく整理したもの)。

vendor\laravel\framework\src\Illuminate\Routing\Pipeline.php
    protected function handleException($passable, Exception $e)
    {
        // ExceptionHandlerを取得
        $handler = app(ExceptionHandler::class);

        // Report を実行(返り値がない)
        $handler->report($e);

        // Render を実行
        $response = $handler->render($passable, $e);

        // Response には exception が付与される
        $response->withException($e);

        // ミドルウェアを通りながら戻る
        return $response;
    }

render

エラー処理の基幹になっているメインメソッドです。最大の役割は「例外クラスの型によって処理を振り分ける」ことですが、実際にはこの render() は完成度が高いのであまり触ることがなく、prepareExceptionprepareResponse が主戦場になってきます。その前提として、render がどういったことをしているのかは把握しておく必要がありそうです。

image.png

Laravelが予め用意している特殊な例外処理は「ログイン認証エラー」と「バリデーションエラー」のみです。
どちらもリダイレクトが発生するため単純なエラー表示ではありません。

それ以外は何らかのカタチでユーザーに表示されます。

prepareResponse / renderHttpException

その「表示されるエラー」をどうするかを決めるのが、この2つのメソッドです。

prepareResponse が条件判断、renderHttpException がLaravel標準ではないカスタムデザインのエラーページを表示するためのメソッド。

image.png

render はカスタマイズしにくいので、表示するエラーだけでなく、独自処理の実装にはこの prepareResponse を書いていくことになりそうです。

2. 適用例/実装例

さて、ここからは、そのフローチャートを踏まえ、よくある要件に対してどこにどのように実装していくかの実例を紹介していきます。

機能・要件

  • 本番環境ではキレイに表示する(表画面)が、デバッグ環境では詳細な標準エラー画面(裏画面)
  • どんな想定外のエラーでも本番環境では裏画面を出さない
  • バリデーションエラーはLaravel標準に完全に合わせる
  • HTTPステータスコードはできる限り使うがエラー画面テンプレートは1つで済ます
  • コンテンツページとCMSでエラー画面を分ける
  • APIでのエラーのフォーマットもLaravelに合わせる
  • APIでは常にJSONで返す
  • SentryやSlackで本番環境のエラーを記録・通知したい
  • 独自のエラーを定義して独自のエラー処理を追加する

上記要件を満たすために、順に実装していきます。
ケーススタディ的に分けているので、ご自身のプロジェクトに必要なところだけをコピペしても使えます。

case. 本番環境ではキレイに表示する(表画面)が、デバッグ環境では詳細な標準エラー画面(裏画面)

image.png

Laravelアプリを作っていると必ずお世話になるエラー画面。
最近はエラー箇所をコード付きで表示してくれてとても便利です。
でも本番環境にデプロイすると急に「Whoops! Something went wrong.」と真っ白な画面になります。
この切替能力の高さ…。どこで制御しているのでしょうか?

A. APP_DEBUG=true とする

もう絶対に間違えない!Laravelでエラーページをカスタマイズするベストプラクティス(5.5) のケース1に紹介されていますが、まず基本中の基本で、Laravelは config('app.debug') でエラー表示の切り替えをしています。このconfig値は、

config/app.php
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/

'debug' => env('APP_DEBUG', false),

とあるので、要するに、環境変数 APP_DEBUG のことです。本番環境でも

APP_DEBUG=true

としておくと、詳細なエラーが表示されます。

#APP_DEBUG=true # そもそも環境変数がなければ、config('app.debug')はデフォルト値 false がセットされる。

が、今回は逆で、本番環境では詳細エラーは表示させない方針で行きます。

case. どんな想定外のエラーでも本番環境では裏画面を出さない(~5.4)

2018.11.20 修正
この機能はLaravel5.5からはprepareResponseに標準で実装されていました…。特に何もしなくても、本番環境ではコード500のHttpExceptionとして、カスタムされたエラー画面がレンダリングされます。

image.png

Laravelは、404 Not Foundに代表される「HTTPエラー」は、Bladeテンプレートを使ったキレイな画面を表示する機能が用意されていますが、それ以外のエラー、例えばDB接続オーバーフローや計算ミス、はたまた文法エラーなどのエラーは単に「Whoops!」と白い画面を出す機能しか用意されていません。

#HTTPエラーは、存在しないURLをリクエストするなど「ユーザーの操作ミスに起因するエラー」なのでプログラマがどれだけ頑張っても絶対に防げないエラー。だからキレイなエラー画面を用意する必要があります。逆に、それ以外のエラーは、プログラマが責任持って全部取っておけ!ということでしょうか?笑

そこで、HTTP以外のエラーをHTTPエラーに変換することで、本番環境で思わぬエラーがあっても「Whoops!」で終わらせず、キレイな画面を表示できるようにします。

A. HTTP以外のエラーをHTTPエラーにする

例外の型を変換して処理方法を変更するのは、まさに prepareException の役割です。

ただ、こうしてもステータスコード500に対する「きれいな画面のテンプレート」を用意しないとキレイに表示されないので、後述の「HTTPエラーページを共通化する」を併用します。

app\Exceptions\Handler.php
// このメソッドを追加します
protected function prepareException(Exception $e)
{
    // デフォルトの動作を邪魔しないように先に実行しておく
    $e = parent::prepareException($e);

    // 特定の例外はステータスコードには特定のステータスコードを与えてもいいと思います
    if ($e instanceof \Illuminate\Session\TokenMismatchException) {
        $e = new HttpException(400, 'フォームの手順が想定と異なるため処理を中断しました');
    }

    // 500 Internal Server Error は HttpExceptionではないのでLaravelのエラー画面が表示される
    // それを HttpException(Status500) に置き換えると、カスタムエラー画面を表示しようとする
    if (!$this->isHttpException($e) && !config('app.debug')) {
        $e = new HttpException(500, $e->getMessage());
    }

    return $e;
}

ちなみに prepareException は Laravel5.3 からの実装です。我が家の古いプロジェクトでは対応できなかった……。

case. バリデーションエラーはLaravel標準に完全に合わせる

image.png

「バリデーションエラーのレスポンスってどうする?」という話はもうしたくない。
WEBでもAPIでも、Laravelがうまいことやってくれるので、HTMLやフロントの実装をそれに合わせましょう。

A. フォームのポストは $errors で受け取る

Bladeテンプレート内だったら、$errors変数はどこでも定義済みで使用できます。

@foreach( $errors->all() as $message )
<div class="alert alert-danger">{{ $message }}</div>
@endforeach

ただ、Laravel CollectiveやいくつかのForm Builderなどは、何も書かなくてもエラーを自動表示してくれます。これについてはまた別の機会に記事を書きたいと思います。

A. APIのリクエストはステータスコード 422 で判断

APIでバリデーションエラーがあると、ステータスコード422で、JSONが返ってきます。

image.png

フロントエンドではそのステータスコードと、上記のような決められたメッセージフォーマットをメッセージ表示などに使用してください。このあたりは詳しくコチラに。また別の機会に記事を書きたいなーと。

Modelでバリデーションする(2行で) + Laravel5のバリデーションの仕組み解説(5秒で)

case. HTTPステータスコードはできる限り使うがエラー画面テンプレートは1つで済ます

image.png

どんなWEBサービスでもその個性をアピールするために、オリジナリティあふれるデザインで着飾ります。でもエラーページが標準のままだったら味気ないですよね。そこはLaravelでも考えられていて、オリジナルデザインのViewテンプレートを用意すればあっという間に!

views/
  errors/
    403.php
    404.php
    500.php
    ...

チョット待って!
これ全部用意するんですか?
いくつ要るんですか?

※ちなみに最大で15種です。

A. HTTPエラーページを共通化する

そう。たかがステータスコードと一行のメッセージしか違わないページのためにテンプレートファイルを量産するのはナンセンス。一枚の共通テンプレートに頑張ってもらいましょう。

詳しいやりかたは Laravel5: エラーページを共通化〜どんなステータスコードでもどんと来い! にも紹介されていますが、結論は renderHttpException 。これがカッコいいWEBのエラーページを作っているメソッドです。

app\Exceptions\Handler.php
/**
 * オリジナルデザインのエラー画面をレンダリングする
 *
 * @param  \Symfony\Component\HttpKernel\Exception\HttpException $e
 * @return \Illuminate\Http\Response
 */
protected function renderHttpException(HttpException $e)
{
    $status = $e->getStatusCode();
    return response()->view("errors.common", // 共通テンプレート
        [
            // VIEWに与える変数
            'exception'   => $e,
            'message'     => $e->getMessage(),
            'status_code' => $status,
        ],
        $status, // レスポンス自体のステータスコード
        $e->getHeaders()
    );
}

テンプレートはこのように。

views\errors\common.blade.php
@extends('layouts.fullwidth')

@section('contents')
<div class="jumbotron">
    <h1 class="display-3">{{$status_code or 'error'}}</h1>
    <p class="lead">{{ $message or 'Error'}}</p>
    <hr class="my-4">
    <p>ご不便をおかけして申し訳ございません。</p>
    <p class="lead">
    <a class="btn btn-primary" href="/" role="button">トップページへ戻る</a>
    </p>
</div>
@endsection

個人的に「ごめんなさい」ってちゃんと言ってくれるエラー画面が好きです。

case .ユーザーページと管理画面でエラー画面を分ける

image.png

管理画面と表画面で共通のデザインを使っているところはあまりないのではないかと思います。
特に管理画面が特定のウェブマスターしか使わないようなサイトだと。であれば、そのデザインを分けたいと思うのは当然です。
今回は単純にURLでそれを判定してみます。

A. WEBエラー画面のレンダリング条件を追加する

「画面に表示するエラー」というところまで処理が絞り込まれていて、あとは「どういう画面」を表示するのか?という条件分岐になるので、renderHttpException を使用します。

app\Exceptions\Handler.php
protected function renderHttpException(HttpException $e)
{
    // URLのディレクトリが /admin で始まる場合
    if( strpos(\Request::path(),'admin/')===0 ){
        // 常にデバッグモードでLaravel標準エラー画面を表示
        config(['app.debug' => true]);
        return parent::convertExceptionToResponse($e);
    }

    // 通常のエラー画面(「HTTPエラーページの共通化」の簡易版)
    $status = $e->getStatusCode();
    return response()->view("errors.common", ['exception' => $e], $status);
}

case. APIでのエラーのフォーマットもLaravelに合わせる

APIのエラーフォーマットってどうするよ?という議論が以前は必要でしたが、Laravel5.5 からは標準のフォーマットが用意されました。特に理由がない限り、これに乗っかっておきましょう。

A. これが Laravel標準のAPIエラーフォーマット

エラーかどうかはステータスコードで判断し、その内容は message に書いてある、というシンプルな構成です。

STATUS_CODE=404
{
    "message": "ご指定のリソースが見つかりません。"
}

デバッグ時には、それに例外クラス、発生場所、トレースが追加されます。

STATUS_CODE=404
{
    "message": "ご指定のリソースが見つかりません。",
    "exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
    "file": "/.../vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
    "line": 937,
    "trace": [
        {
        "file": "/.../vendor/laravel/framework/src/Illuminate/Foundation/helpers.php",
        "line": 35,
        "function": "abort",
        "class": "Illuminate\\Foundation\\Application",
        "type": "->"
        },

ポイントは、よくありがちな「独自のエラーコード」を持っていないことです(HTTPステータスコードはレスポンス時のステータスコードとして返すけど、ボディには持っていません)。どんなタイプのエラーが起きたのか?を区別するためには、エラーコードではなく「例外クラスの型(クラス名)」で識別する、という考え方なんでしょうね。

さてこのフォーマット、実際には下記の2つのメソッドが処理を担当しています。
逆に、これをオーバーライドすればAPIエラーメッセージのフォーマットをカスタマイズできるということ。そんなこと言ってもエラーコードがほしい!(^^)という場合はここに追加しましょう。

vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php
    protected function prepareJsonResponse($request, Exception $e)
    {
        // 略...
    }

    protected function convertExceptionToArray(Exception $e)
    {
        return config('app.debug') ? [
            'message' => $e->getMessage(),
            'exception' => get_class($e),
            // 略...
        ] : [
            'message' => $this->isHttpException($e) ? $e->getMessage() : 'Server Error',
        ];
    }

case. APIでは常にJSONで返す

A. JSONがリクエストされている時はレスポンスを変える(~5.6)

image.png

LaravelはリクエストされているのがWEBページなのか、JSONなのかを判定するメソッドを持っています。
バリデーションエラー処理にはそれが使われているのですが、それ以外では使用されていないので、APIリクエストでエラーが起こるとWEBのエラーページが返ってきてしまいます。

それを、JSONで返ってくるようにするには、WEBエラー画面を準備しているところで条件分岐します。

ちなみにこのコードはLaravel5.7で標準実装されています(コピペしてきました笑)。つまり、Laravel5.7以上だと特に何もしなくても、APIのエラーはJSONで返ってくるようになりました。

app\Exceptions\Handler.php
    protected function prepareResponse($request, Exception $e)
    {
        if( $request->expectsJson() ){
            return $this->prepareJsonResponse($request, $e);
        }

        return parent::prepareResponse($request, $e);
    }

    protected function prepareJsonResponse($request, Exception $e)
    {
        return new JsonResponse(
            $this->convertExceptionToArray($e),
            $this->isHttpException($e) ? $e->getStatusCode() : 500,
            $this->isHttpException($e) ? $e->getHeaders() : [],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        );
    }

A. APIへのアクセス時に常にJSONレスポンスをリクエスト

image.png

Laravel5.7でAPIのJSONエラーメッセージをカスタマイズしたい に詳しく書きましたが、Laravelの自動判定に任せずに、アクセスされたエンドポイントによって、常にJSONでレスポンスするには、ミドルウェアを入れるのが理に適っていると思います。

app/Http/Middleware/RequireJson.php
<?php namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Response;

/**
 * 強制的にJSONをリクエスト
 * リクエストヘッダに Accept: application/json を付与する
 * 
 * @link https://stackoverflow.com/questions/46035072/enforce-json-middleware
 */
class RequireJson
{
    public function handle($request, Closure $next)
    {
        // リクエストヘッダに Accept:application/json を加える
        $request->headers->set('Accept','application/json');

        $response = $next($request);

        return $response;
    }
}

APIの呼び出しには常にこれを適用します。

app/Http/Kernel.php
        //....
        'api' => [
            'throttle:60,1',
            'bindings',
            \App\Http\Middleware\RequireJson::class, // 追加
        ],
        //....

case. SentryやSlackで本番環境のエラーを記録・通知したい

A. report メソッドの登場

WEBサービスを使っているユーザーではなく、開発・管理している開発者サイドが、どんなエラーがいつ起きているかを知るための仕組みが report です。本番環境で起きるエラーは、ログを取っていても読みに行きにくいし、種類よりも頻度が重要だったりするので、ぜひエラー統計サービスを活用しましょう。

HttpException はユーザー操作に起因する取れないエラーなので報告は要らないという場合は、$dontReport に書いておきます(reportをスルーします)。Sentryの件数節約などにご活用ください。
ちなみに、render よりも前に、独立して実行されるので、例えば prepareException などで例外の型を変換しても影響を受けません。気にせず「元の例外の型」で考えます。

app\Exceptions\Handler.php
    /**
     * ここに書いた例外クラスは報告されない
     * reportはrenderの前に独立して実行されるのでprepareExceptionでの変換される前の生クラスなのが注意点
     */
    protected $dontReport = [
        \Illuminate\Auth\AuthenticationException::class,
        \Illuminate\Auth\Access\AuthorizationException::class,
        \Symfony\Component\HttpKernel\Exception\HttpException::class,
        \Illuminate\Database\Eloquent\ModelNotFoundException::class,
        \Illuminate\Session\TokenMismatchException::class,
        \Illuminate\Validation\ValidationException::class,
    ];

    /**
     * report
     * 管理者・開発者向けにエラーが起こったことを通知・記録する仕組み。
     */
    public function report(Exception $exception)
    {
        // 本番環境のみ Sentry に例外情報を送信
        if ($this->shouldReport($exception) && app()->environment('production')) {
            app('sentry')->captureException($exception);
        }

        // 親クラスではエラーログに記録しています
        parent::report($exception);
    }

case. 独自のエラーを定義して独自のエラー処理を追加する

Laravelはフレームワークです。「基本的なことは書いておくから、必要なものを実装してね」というスタンス。それは「外枠」であって「中身」がありません。エラー処理もそう。バリデーションや認証はかなりガッツリはいっているし標準エラー画面は機能的だし、これだけで良いやと思いがちですが、所詮これらは「外枠」です。アプリケーション独自のエラーとそのルールの実装という「中身」が必要なはず。

エラー処理の解説記事は、ここが最終ゴールで、スタート地点。

基本フローはこのようになります。

image.png

A. 競合違反を実装してみる

例えば、データベースの同じ情報に対して、複数のユーザーが同時に編集したとします。
競合違反です。

競合が起きたということは、ユーザーは編集画面にいて、なにかデータを入力して送信したはず。これはバリデーションエラーと同じような状況。試しに、この「競合違反」に対して「前の画面にリダイレクトバックする」という処理を実装してみます。

例外クラスを定義する

単に \Exception を継承してもいいのですが、ちょうど目的を同じくしたHTTPエラーがあったので、それを継承しています。

app\Exceptions\ConflictException.php
namespace App\Exceptions;

use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

/**
 * 競合が発生
 */
class ConflictException extends ConflictHttpException
{
    //
}

条件分岐する

prepareResponse で特定の例外に対して条件分岐します。ここでは分岐だけにして、実際の処理は別のメソッドに書くようにします。

app\Exceptions\Handler.php
    protected function prepareResponse($request, Exception $e)
    {
        // 競合違反を条件分岐
        if ($e instanceof ConflictHttpException) {
            return $this->invalidHttpRequest($request, $e);
        }

        return parent::prepareResponse($request, $e);
    }

独自処理を書く

最後に処理を書きます。
今回はバリデーション違反を処理する invalid() というメソッドとほぼ同じ処理をしています。

app\Exceptions\Handler.php
    protected function invalidHttpRequest($request, HttpException $exception)
    {
        $url = url()->previous();

        return redirect($url)
            ->withInput($request->except($this->dontFlash));
    }

投げる

アプリケーションの任意の場所で、この例外を投げます。
処理は分断され、自動的にリダイレクトバックされます。

app\Http\Controllers\SomeController.php
    protected function update(Request $request, $id)
    {
        //...
        throw new \App\Exceptions\ConflictException('競合違反です');
        //...
        $model->save();   // とか
        return view(...); // といった処理は無視される
    }

感想

Laravelのエラー処理……。ざっと調べた限りでもこのくらい記事が出てきました。

laravel5.5 フォームリクエストのバリデーションエラーをjsonで返す方法
Laravelにグローバル関数を追加しようぞ。お手軽discordエラー通知
Laravel エラー時のレスポンス形式の変更(JSON)
Laravel でオレオレ例外処理を作成する
Laravelで独自例外処理を実装する(楽観的排他制御andトランザクション処理)

それぞれ読ませていただいて勉強になりましたが、個別の要件に対する解決策ばかりで、全体的な流れを扱っている記事がなかなか見つかりませんでした。

この「全体的な流れ」つまり、「エラーは、とにかくどこからでも何でも投げ(※)アプリケーションの共通の部分で受け取って(※)、仕分けして、適切な処理をする」というもの。これって別にLaravelに限ったことではなく、PHPの(というより最近のプログラミング言語の)基本的な設計方法になっているんですよね。

つまり、Laravelのエラー処理ってなんだかよくわからんなぁ、と思っていた原因の根本は、最近のプログラミング言語のエラー処理の考え方がよくわかってなかったからで、今回Laravelのそれを通じて、投げてキャッチする処理フローが、なんとなくつかめたような気がします。

※何でも投げ……もちろんどんなものを投げるのかは予めきちんと分類しておく必要があります。\Exceptionばかり投げていてはダメです。

※共通の部分で受け取って……もちろんエラーの種類によってはその場ですぐ解決して通常処理に戻したほうがいいことも多いです。それが try & catch で、catch するクラスの範囲は慎重に設計する必要があります。catch( \Exception $e ) ばかりじゃダメです。

ところで、「とにかくどこからでも投げ、共通部分で受け取って処理」というのは、例えばLaravelのEventや、フロントエンドでよくある Reactive Programming など、Observerパターンと言われるようなものと同じではないかーと気づき、それらに対する理解もちょっと深まったのは思わぬ収穫でした。

今回、ユースケース的にいろんな手法を紹介しましたが、それぞれの解説が概要程度になってしまっていることは否めません。また別の機会に詳細記事を用意できれば……と思いますが、なにかわからないことや、こうしたい、といったことがあれば、気軽にご質問いただけると嬉しいです。

僕自身、まだエラー処理は駆け出しなので、さらに勉強して制度の高いものにしていきたいと思います。

こんな記事も書いています

Laravelのちょっとマニアックな視点から、誰も書かない記事を書いています(笑
合わせてご覧いただけると幸いです(^^)