Laravel6の例外処理

Laravel6の例外処理の動作を確認したのでメモとして残しておく。
最新版については、リポジトリ等参のこと。

# 処理の流れ

ブラウザからのアクセスによって例外が発生した場合、Illuminate\Routing\Pipeline::handleException()  で処理されるようだ。

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Routing/Pipeline.php

※コンソール経由の場合は未確認

~~~ php
/**
 * Handle the given exception.
 *
 * @param  mixed  $passable
 * @param  \Exception  $e
 * @return mixed
 *
 * @throws \Exception
 */

protected function handleException($passable, Exception $e)
{
    if (! $this->container->bound(ExceptionHandler::class) ||
        ! $passable instanceof Request) {
        throw $e;
    }

    $handler = $this->container->make(ExceptionHandler::class);

    $handler->report($e);

    $response = $handler->render($passable, $e);

    if (method_exists($response, 'withException')) {
        $response->withException($e);
    }

    return $response;
}

~~~

Laravelインストール直後の状態では$handlerに渡されるのは App\Exceptions\Handler になるので、

https://github.com/laravel/laravel/blob/6.x/app/Exceptions/Handler.php

App\Exceptions\Handler::report()

~~~ php
public function report(Exception $exception)
{
    parent::report($exception);
}
~~~

App\Exceptions\Handler::render()

~~~ php 
public function render($request, Exception $exception)
{
    return parent::render($request, $exception);
}
~~~

の順序で実行される。

見ての通り、親クラスのreport() / render() を呼んでいるだけなので、

- カスタマイズしたい場合、App\Exceptions\Handler を修正
- インストール直後の状態ででのような動きをするのか、は親クラスの Illuminate\Foundation\Exceptions\Handler を見れば良い

となる。



次項で、Illuminate\Foundation\Exceptions\Handler の実装がどうなっているのか確認する。



# デフォルト動作の確認(Illuminate\Foundation\Exceptions\Handler)

## Illuminate\Foundation\Exceptions\Handler::report()

まずreport() について

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Foundation/Exceptions/Handler.php

下記ソースコードの通り。大雑把に言えば、

- 発生した例外が shouldntReport に含まれている場合は何もせず終了
- 発生した例外が report() メソッドを持つ場合、その結果を返す
- 上記いずれにも当てはまらない場合、Loggerでログを吐く

となる。

~~~ php
    /**
     * Report or log an exception.
     *
     * @param  \Exception  $e
     * @return void
     *
     * @throws \Exception
     */
    public function report(Exception $e)
    {
        if ($this->shouldntReport($e)) {
            return;
        }

        if (is_callable($reportCallable = [$e, 'report'])) {
            return $this->container->call($reportCallable);
        }
    
        try {
            $logger = $this->container->make(LoggerInterface::class);
        } catch (Exception $ex) {
            throw $e;
        }
    
        $logger->error(
            $e->getMessage(),
            array_merge(
                $this->exceptionContext($e),
                $this->context(),
                ['exception' => $e]
            )
        );
    }

~~~

## Illuminate\Foundation\Exceptions\Handler::render()

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Foundation/Exceptions/Handler.php

次に、Illuminate\Foundation\Exceptions\Handler::render() について。

下記ソースコードの通りだが、reportより少し複雑。大雑把には、

- 発生した例外がrender() または toResponse() を持つ場合、その結果を返す

- いくつかの特定の例外に対しては、固有の処理を行う 

  ※後述するが、Laravelフレームワーク側の話が多いので、通常はあまり気にする必要がない

- 上記いずれにも当てはまらない場合、$request->expectsJson() の結果に応じてprepareJsonResponse() またはprepareResponse() の結果を返す

  ※開発者が気にするのはだいたいの場合こっち

  

~~~ php
    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $e
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Exception
     */
    public function render($request, Exception $e)
    {
        if (method_exists($e, 'render') && $response = $e->render($request)) {
            return Router::toResponse($request, $response);
        } elseif ($e instanceof Responsable) {
            return $e->toResponse($request);
        }

        $e = $this->prepareException($e);

        if ($e instanceof HttpResponseException) {
            return $e->getResponse();
        } elseif ($e instanceof AuthenticationException) {
            return $this->unauthenticated($request, $e);
        } elseif ($e instanceof ValidationException) {
            return $this->convertValidationExceptionToResponse($e, $request);
        }
        return $request->expectsJson()
                    ? $this->prepareJsonResponse($request, $e)
                    : $this->prepareResponse($request, $e);
    }

~~~

## いくつかの特定の例外について
下記3種類の例外は、それぞれ固有の処理が行われているので注意。
### Illuminate\Http\Exceptions\HttpResponseException
HttpResponseException型の例外が発生した場合は、例外クラスに実装されているgetResponse()の結果がレスポンスとして返る。

### Illuminate\Auth\AuthenticationException
認証に失敗した場合。この例外の場合は、unauthenticated() に処理が渡され、ステータスコード401でレスポンスが返る。
https://readouble.com/laravel/6.x/ja/authentication.html
と合わせて理解しておくと良い。

### Illuminate\Validation\ValidationException
バリデーションに失敗した場合。この例外の場合は、convertValidationExceptionToResponse() に処理が渡されて、入力内容とともに一つ前の画面にリダイレクトされる。
https://readouble.com/laravel/6.x/ja/validation.html#form-request-validation
と合わせて理解しておくと良い。

## いくつかの特定の例外について(prepareException()で型が書き換えられるもの)
### Illuminate\Database\Eloquent\ModelNotFoundException
DBのレコードが見つからない（findOrFail() でfailする）と発生するやつ。NotFoundHttpExceptionに書き換えられ、ステータスコード404でレスポンスが返る。

### Illuminate\Auth\Access\AuthorizationException
許可されていない操作を実行しようとすると発生する例外。AccessDeniedHttpExceptionに書き換えられ、ステータスコード403でレスポンスが返る。
https://readouble.com/laravel/6.x/ja/authorization.html
この辺と合わせて理解しておくと良い

### Illuminate\Session\TokenMismatchException
CSRFトークンの検証が失敗すると発生する例外。HttpExceptionに書き換えられ、ステータスコード419でレスポンスが返る。
https://readouble.com/laravel/6.x/ja/csrf.html
この辺とあわせて。

### Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException
よくわからん。ホスト名が一致しないと発生する？？ステータスコード404でレスポンスが返る。
https://www.larajapan.com/2019/07/01/changelog%E3%81%AF%E9%9D%A2%E7%99%BD%E3%81%84/
過去ステータスコードが 404 => 500 => 404 という経緯で変わってるらしく、この辺の話題ばっかりヒットする


## prepareResponse() の動作について
設定値 APP_DEBUG により処理が分岐するので一応注意が必要。
発生した例外が HttpExceptionInterface を実装していない場合
- デバッグモードtrueならデバッグ用の画面が返る
- デバッグモードfalseならステータスコード500でレスポンスが返る
発生した例外が HttpExceptionInterface を実装している場合、例外に定義されている内容に従ってレスポンスが返る。

~~~ php
    protected function prepareResponse($request, Exception $e)
    {
        if (! $this->isHttpException($e) && config('app.debug')) {
            return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e);
        }

        if (! $this->isHttpException($e)) {
            $e = new HttpException(500, $e->getMessage());
        }

        return $this->toIlluminateResponse(
            $this->renderHttpException($e), $e
        );
    }
~~~