556
530

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 5 years have passed since last update.

Laravel #2Advent Calendar 2018

Day 2

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

Last updated at Posted at 2018-11-18

image.png

TL;DR

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

バリデータ編もあります。
フロー図で理解するLaravelバリデータの仕組みと、チーム開発でのケーススタディ

動機

  • 個人的にエラー処理の仕組みを理解するために書いたチャートです
  • 自分で勉強しようとしたとき、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. convertExceptionToResponse 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)]
(https://qiita.com/kamukiriri/items/fd03161998236622fd17) のケース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 のことです。本番環境でも

.env
APP_DEBUG=true

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

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

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

case. どんな想定外のエラーでも本番環境ではキレイな画面を出す(~5.4)

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

2019.07.01 修正
掲載コードにバグがありました……。Laravel5.5にならって、prepareResponseに追記する方法に変更しました。

image.png

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

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

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

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

例外の型を変換して処理方法を変更するので prepareException の出番かと思われるところですが(そう考えちゃっていました………)、「最終的に取りこぼされた想定外のエラー」を「(デバッグ画面か、きれいな画面か)どんな表示の仕方をするか?」を考えるので、 'prepareResponse` が適任です。

ただ、ここで行うのはあくまで例外の型を変換するだけで、実際に表示するには、ステータスコード500に対する「きれいな画面のテンプレート」を用意する必要があるので、後述の「HTTPエラーページを共通化する」も合わせ技として使います。

app\Exceptions\Handler.php
// このメソッドを追加します
protected function prepareResponse($request, Exception $e)
{
    // デバッグ以外の環境で、HTTPじゃない例外が起これば、HTTP例外の500に変更する
    if ( !$this->isHttpException($e) && !config('app.debug')) {
        $e = new HttpException(500, $e->getMessage(), $e);
    }

    return parent::prepareResponse($request, $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で返ってくるようになりました。

2019-03 追記
convertExceptionToArray メソッドはLaravel5.5からの実装でした。5.4以前のバージョンではそのメソッドも必要です。追記させていただきました。

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
        );
    }

    protected function convertExceptionToArray(Exception $e)
    {
        return config('app.debug') ? [
            'message' => $e->getMessage(),
            'exception' => get_class($e),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => collect($e->getTrace())->map(function ($trace) {
                return Arr::except($trace, ['args']);
            })->all(),
        ] : [
            'message' => $this->isHttpException($e) ? $e->getMessage() : 'Server Error',
        ];
    }

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(...); // といった処理は無視される
    }

appendix. 応用例 Laravelバリデーションエラーと同じ処理をする独自例外を作る

image.png

「独自例外」の応用例を紹介します。

#ずっとQiitaのもくじ欄に見出しだけあったので、気になってた人も多かったかと思います!(いないよ!)
#記事をコメントアウト<!-- -->していると、本文には出ないけどもくじには拾われちゃうみたい……。

例えば、コントローラから重要なロジックを切り離し、ユースケース的なものやドメイン的なものといった「レイヤードアーキテクチャ」を考えていった場合、その内部の内部、たとえばエンティティ的なものに不正な値が入った場合、どんなエラーを投げますか? そりゃまぁいろんなやり方があるとは思いますが、その中で特に バッドケース と断定してもいいのが「Laravelバリデータで、ValidationExceptionを起こさせること」です。

自分で、ここ( フロー図で理解するLaravelバリデータの仕組みと、チーム開発でのケーススタディ )に「できるよ」と書いたにもかかわらず。

後出しでズルい感じがありますが「できるよ」と「やったほうがいいよ」はイコールではありません。

独自例外を定義する

いろんなやり方がありますが、なかなかのグッドケースの1つは、Laravelのことは一切忘れて、自分にイチバン都合がいい、エンティティ的なものからとても使いやすい、独自の例外を作ることです。例えば、こう。

namespace App\Usecases;
class ParameterInvalidException extends \Exception
{
    public $param;
    public $message;

    public function __construct(string $param, string $message)
    {
        $this->param = $param;
        $this->message = $message;
    }
}

投げる

さぁ、もう受け取るやつのことなんか忘れて、思いのままに、投げるといい!

// 思いのままに独自の判定ロジックを実装する
if ( $this->validateSomething( $order_id, $user_id ) ) {
    // 思いのままに投げる
    throw new ParameterInvalidException('order_id', "ユーザー $user_id にはオーダー $order_id にアクセスできません。");
}

Laravelさん、がんばって!

さぁどこからともなく飛んできたこの「ParameterInvalidException」。それを処理する責任はその呼び出し元。その大ボスはLaravelさんです。

もちろん何もしなくても、Laravelsさんはこれを逃さずキャッチして普通に「500エラー」を出力します。

が、これをあえて「バリデーションエラー」として処理させてみます。
ここで利用するのも prepareException。エラー型の変換です。

型変換

app/Exceptions/Handler.php
    protected function prepareException(Exception $e)
    {
        // デフォルトの動作を邪魔しないように先に実行しておく
        $e = parent::prepareException($e);
    
        // バリデーションエラーに似た独自のエラーをバリデーションエラーに
        if ($e instanceof \App\Usecases\ParameterInvalidException) {
            $validator = Validator::make([], []);
            $validator->errors()->add( $e->param, $e->message );
            // 新しく作った例外は投げずに置き換える
            $e = new ValidationException($validator);
        }
    
        return $e;
    }

これで、ParameterInvalidException という独自エラーがバリデーションエラーとして動作するようになります。

先程の throw new ParameterInvalidException('order_id', "...") 第1引数と同じ name属性をもつ INPUTがあれば、そこにダイレクトに、徒然書いたエラーメッセージが表示されることでしょう……。

諸注意

と、書いたコードは、本番運用の実績はあれど、あくまで簡易的ものなので……

  • 第1引数の変数名が name属性 と一致してないといけない
  • フォームの name属性 を意識して例外を投げるの? いえいえ、name属性にないものが飛んできたときにどうするかを考えるのはLaravelの仕事です(とはいえある程度は「変数名のガイドライン」はあったほうがいい)
  • 全部 ValidationException にしてもいいの? そうとも限りません。「name属性にないもの」もしかり。どんなときにどんな例外として処理するかを考えるのは、Laravelの仕事。
  • とはいえ、何でもかんでも、Laravelとその導入担当者の仕事にしちゃうとパンクするので、内部コードも ある程度は Laravelのことも理解して、意識してあげたほうが良いと思います。

感想

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

556
530
7

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
556
530

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?