WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

PHPのエラーを理解してLaravelのエラーハンドリングをカスタマイズする

  • こんにちは。ホワイトプラスでエンジニアをしている古賀です。
  • 弊社が採用している言語の1つにPHP(FWはLaravel)があり、定期的にバージョンアップを行なっています。
  • PHP8.0の下位互換性の無い変更の一つに、多くのWarningがError例外に変換されるようになりました。
  • 日頃からWarning/Noticeを出さないようにする、見つけたら潰すという習慣があると慌てなくて良いですよね。
  • しかし、Warning/Notice に気づけない事象に遭遇し、Laravelのエラーハンドリングを変更することで対処しました。ここではその方法を紹介します。

前提

  • PHP8.1
  • Laravel v9.25.1

課題:現在のエラーレベルに該当しないエラーのログ出力

  • 存在しない配列キーにアクセスした時、PHP8.1ではWarningレベルのエラーが発生します。
<?php

$arr = [];
$arr[1];  // PHP Warning: Undefined array key 1
  • エラーレベルを E_ALL に設定した上で、Laravelを起動し以下のコードを実行すると、ErrorException となって処理が落ちます。
<?php

error_reporting(E_ALL);  // 便宜上、コード内でエラーレベルを定義
$arr = [];
$arr[1];      // ErrorException: Undefined array key 1.
echo 'test';  // 実行されない
  • これはLaravel側がエラーレベルに該当するエラーが発生した時に、ErrorException を投げているため、Warningであっても処理が落ちるようになっています。厳格でいいですね。
  • しかし、レガシーコードを抱えているが故に、エラーレベルを落として運用したい事情もあると思います。今度はエラーレベルをFatal Errorに限定して、同じコードを実行してみます。
<?php

error_reporting(E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR);
$arr = [];
$arr[1];      // ここで Warning のログが出て欲しいが出ない
echo 'test';  // 'test'
  • 処理が正常終了しました。処理を落とさないのは想定通りですが、Warningのログが出力されておらず、これだとWarningに気づけません。
  • そこで、現在のエラーレベルに該当しないエラーのログを出力するための対策を行います。
  • その前に、まずはPHPのエラーとLaravelのエラーハンドリングについて説明し、その後で対策を提示したいと思います。

例外の捕捉

  • アプリケーションコードでは try-catch を使った例外ハンドリングを行うことが多いと思います。下記では、RuntimeException が発生した場合のみ特定の処理をcatch句で行います。
<?php

try {
    // 処理の実行
}catch (RuntimeException $e) {
    // 失敗時の処理
}
  • catch句で捕捉できない ExceptionError が発生した時、ログ出力等の任意の処理を入れたい場合にはどうすれば良いでしょうか。
  • そんな時に使えるのが set_exception_handler です。
  • この関数は、try/catch ブロックの中でキャッチされなかった場合に実行されるcallbackを定義する事ができます。
<?php

set_exception_handler(
    function(Throwable $e) {
        // 任意の処理
    }
);
  • ただし、この方法で捕捉できるのはスロー可能なオブジェクト、つまり Throwable を実装している ExceptionError だけです。
  • PHPには Throwable を実装していないエラーが存在しており、それを捕捉するには別の方法を取らなければなりません。

Throwable でないエラーを補足する

  • Throwable を実装していないエラーは、主にPHPの処理系内部で発生するもので、E_ERRORやE_PARSEなどがあります。(それ以外のエラーはこちら
  • これらは処理の継続が可能なものと、不可能なものの2種類に分けられます。

処理の継続が可能なエラー(Warning/Notice)

  • set_error_handler は第2引数で設定した error_levels に該当するエラーが発生した時に、ユーザ定義の callback を実行する関数です。
  • これを使うことで、Warning/Notice が発生した時に任意の処理を実行する事ができます。
<?php

set_error_handler(
    function (int $errno, string $errstr, string $errfile, int $errline, array $errcontext = []) {
        // Warning/Notice発生時に実行したい処理
    }
);
  • callbackを実行した後はエラーの発生元に戻って、処理が継続されます。

処理の継続が不可能なエラー(Fatal Error)

  • こちらのエラーに関しては、 set_error_handler が実行されません。なので、スクリプト処理が完了した時に呼ばれる register_shutdown_function を使います。
<?php

register_shutdown_function(
    function() {
        if (!is_null($error = error_get_last()) && in_array($error['type'], [E_COMPILE_ERROR, E_CORE_ERROR, E_ERROR, E_PARSE])) {
            // Fatal Error発生時に実行したい処理
        }
    }
);
  • register_shutdown_function の callback内で、直前で発生したエラーを取得しFatal Error かどうかを判定することで、Fatal Error発生時に任意の処理を実行できます。

Laravel のエラーハンドリング

  • 上記のように、set_exception_handlerset_error_handlerregister_shutdown_function を合わせて使用することで、PHPのエラーを捕捉できることが分かりました。
  • このようなハンドリングはFW側でいい感じに用意してくれていて、Laravel では以下のようになっています。
<?php

class HandleExceptions
{
    public function bootstrap(Application $app)
    {
        self::$reservedMemory = str_repeat('x', 10240);
        $this->app = $app;
        error_reporting(-1);
        
        // この3行に注目!
        set_error_handler([$this, 'handleError']);
        set_exception_handler([$this, 'handleException']);
        register_shutdown_function([$this, 'handleShutdown']);
        
        if (! $app->environment('testing')) {
            ini_set('display_errors', 'Off');
        }
    }

    public function handleError($level, $message, $file = '', $line = 0, $context = [])
    {
        if ($this->isDeprecation($level)) {
            return $this->handleDeprecationError($message, $file, $line, $level);
        }

        if (error_reporting() & $level) {
            throw new ErrorException($message, 0, $level, $file, $line);
        }
    }

    public function handleException(Throwable $e)
    {
        self::$reservedMemory = null;

        try {
            $this->getExceptionHandler()->report($e);
        } catch (Exception $e) {
            //
        }

        if (static::$app->runningInConsole()) {
            $this->renderForConsole($e);
        } else {
            $this->renderHttpResponse($e);
        }
    }

    public function handleShutdown()
    {
        self::$reservedMemory = null;
        if (! is_null($error = error_get_last()) && $this->isFatal($error['type'])) {
            $this->handleException($this->fatalErrorFromPhpError($error, 0));
        }
    }
}
  • まず、Laravel の起動処理時に vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.phpbootstrap() が呼ばれ、以下のようにハンドリングするように設定しています。
    • handleException()
      • 例外が捕捉されなかった時に実行されるcallback。
      • ロギングやレスポンスの返却などを行うエラー処理の本体。
    • handleShutdown()
      • スクリプト処理が完了した時に実行されるcallback。
      • 最後に発生したエラーが Fatal Errorの場合に、handleException()を実行している。
    • handleError()
      • Warning/Noticeが発生した時に実行されるcallback。
      • 設定したエラーレベルに該当するエラーが発生した時に ErrorException を投げて、それが捕捉されないことで間接的に handleException() に処理を委譲している。
  • これで Laravelのエラーハンドリングがどのような動きになっているか理解できたと思います。

Laravel でエラー時のcallbackを変更する

  • さて、冒頭の課題「現在のエラーレベルに該当しないエラーのログを出力する」の対策を考えます。
  • 先ほど示したLaravelの handleError() の実装をもう一度見てみます。
<?php

class HandleExceptions
{
    public function handleError($level, $message, $file = '', $line = 0, $context = [])
    {
        // 省略
        if (error_reporting() & $level) {
            throw new ErrorException($message, 0, $level, $file, $line);
        }
    }
    // 省略
  • 現在のエラーレベルに該当しない場合は ErrorException が投げられず、エラー発生元に戻って処理が継続されるようになっていますね。
  • なので、ErrorExceptionが投げられない場合にもログが出力されるように設定できると良いですね。
  • set_error_handler でユーザ定義のcallbackを作成し、その中でログ出力したものが実行されれば解決しそうです。これを Laravel の設定ファイルである AppServiceProviderboot() 内に記述していきます。
<?php

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->setErrorHandler();
    }

    // 独自のエラーハンドラを設定
    private function setErrorHandler()
    {
        $laravelHandler = $this->getErrorHandler();
        $callback = function ($level, $message, $file, $line) use ($laravelHandler) {
            $this->logging($message, $file, $line);
            if (!is_callable($laravelHandler)) {
                return false;
            }
            return call_user_func($laravelHandler, $level, $message, $file, $line);
        };
        set_error_handler($callback);
    }

    /**
     * 現在設定されているエラーハンドラを取得する。
     * 純粋にエラーハンドラを取得するための関数が無いため、set_error_handler() の戻り値によって取得し、
     * restore_error_handler() で元々設定されていたエラーハンドラに戻す
     *
     * @return callable|null
     */
    private function getErrorHandler()
    {
        $handler = set_error_handler(static function () {
        });
        restore_error_handler();
        return $handler;
    }

    private function logging(string $message, string $file, int $line): void
    {
        // ログ出力処理
    }
}
  • まずはLaravelデフォルトのcallbackを取得するために、set_error_handler を実行します。
  • ユーザ定義のcallback($callback) の中で最初にログ出力を行い、その後に取得したcallback($laravelHandler)を実行するように、set_error_handler を実行します。
  • これで、現在のエラーレベルに該当しないエラーが発生してもログが出力されるようにできました!

さいごに

ホワイトプラスでは、ビジョンバリューに共感していただけるエンジニアを募集しています!

ネットクリーニングの「リネット」など「生活領域×テクノロジー」で事業を展開している会社です。どんな会社か気になった方はオウンドメディア「ホワプラSTYLE」をぜひご覧ください。

オンラインでカジュアル面談もできますので、ぜひお気軽にお問い合わせください。

open.talentio.com open.talentio.com open.talentio.com