Laravelでpackageを切るときの自作の例外ハンドラ

2025.10.31 09:00
2025.10.31 09:56
Laravelでpackageを切るときの自作の例外ハンドラ

正直、例外ハンドラはフレームワーク側に任せてきた派です。
でもパッケージ分割を始めると、「このモジュールの NotFound だけ、404+専用ビューで返したい」「APIの時はJSONにしたい」みたいなモジュール固有の流儀が出てきました。
というわけで、アプリ全体の Handler を置き換えずに、既存 Handler に“後付け”で機能を生やす方式を試してみました。

ねらい(ざっくり)

  • モジュール固有のドメイン例外(例:SpotNotFoundException)を、404+専用文言に変換したい
  • HTMLAPI(JSON) をどちらもサポート(expectsJson() 判定)
  • アプリの App\Exceptions\Handler はそのまま。パッケージ側で登録するだけ

仕上がりイメージ

  • Generalモジュール内で SpotNotFoundException を投げると…
    • ブラウザ→ 404/専用ビュー general::errors.not-found
    • API→ {"message":"Spot not found: 103"} 的なJSONを404で返す
  • ログには URL / UA / IP など運用に必要なメタ情報を記録

実装

1) ドメイン例外(基底&個別)

<?php declare(strict_types=1);

namespace Tool\General\Domain\Exceptions;

use RuntimeException;

interface DomainExceptionInterface {}

/**
 * NotFoundException(基底)
 */
class NotFoundException extends RuntimeException implements DomainExceptionInterface {}

/**
 * SpotNotFoundException(個別)
 */
class SpotNotFoundException extends NotFoundException
{
    public function __construct(int $spotId)
    {
        parent::__construct("Spot not found: {$spotId}");
    }
}

2) モジュール専用「例外ハンドラ登録クラス」

ここがキモ。アプリ本体の Handler を差し替えず、renderable() コールバックを後付けします。

<?php declare(strict_types=1);

namespace Tool\General\Domain\Exceptions;

use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;

final class GeneralExceptionHandler
{
    // ==================================================
    // エントリ
    // ==================================================
    public static function register(object $handler): void
    {
        // ここにモジュール固有の登録を追加していく
        self::registerNewsNotFound($handler);
        self::registerSpotNotFound($handler);
    }

    // ==================================================
    // NewsNotFound → 404
    // ==================================================
    private static function registerNewsNotFound(object $handler): void
    {
        if (!method_exists($handler, 'renderable')) {
            return; // 念のため防御
        }

        $handler->renderable(function (NewsNotFoundException $e, Request $request) {
            self::logNotFound('News not found', $e, $request);

            if ($request->expectsJson()) {
                return response()->json([
                    'message' => $e->getMessage(),
                ], 404);
            }

            return response()->view('general::errors.not-found', [
                'title'    => 'お知らせ記事が見つかりません',
                'message'  => "申し訳ありません、お探しのお知らせ記事が見つかりませんでした。\nページが変更になったか、アドレスが違っている可能性があります。\nお手数ですが、トップページから今一度お求めのページをお探しください。",
                'backUrl'  => '/news',
                'backText' => 'お知らせ一覧に戻る',
            ], 404);
        });
    }

    // ==================================================
    // SpotNotFound → 404
    // ==================================================
    private static function registerSpotNotFound(object $handler): void
    {
        if (!method_exists($handler, 'renderable')) {
            return;
        }

        $handler->renderable(function (SpotNotFoundException $e, Request $request) {
            self::logNotFound('Spot not found', $e, $request);

            if ($request->expectsJson()) {
                return response()->json([
                    'message' => $e->getMessage(),
                ], 404);
            }

            return response()->view('general::errors.not-found', [
                'title'    => 'お探しの事業所が見つかりません',
                'message'  => "申し訳ありません、お探しの事業所が見つかりませんでした。\nページが変更になったか、アドレスが違っている可能性があります。\nお手数ですが、トップページから今一度お求めのページをお探しください。",
                'backUrl'  => '/spots',
                'backText' => '事業所一覧に戻る',
            ], 404);
        });
    }

    // ==================================================
    // 共通: 運用向けログ
    // ==================================================
    private static function logNotFound(string $label, \Throwable $e, Request $request): void
    {
        Log::warning($label, [
            'exception'  => get_class($e),
            'message'    => $e->getMessage(),
            'url'        => $request->fullUrl(),
            'user_agent' => $request->userAgent(),
            'ip'         => $request->ip(),
        ]);
    }
}

3) ServiceProvider で「後付け登録」

ポイントExceptionHandler を自前バインドしない。
afterResolving()既存 Handler 解決後に登録します。

<?php declare(strict_types=1);

namespace Tool\General\Infrastructure\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Tool\General\Domain\Exceptions\GeneralExceptionHandler;

final class GeneralServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 既存の Handler がコンテナから解決されたタイミングで“後付け”
        $this->app->afterResolving(ExceptionHandler::class, function ($handler) {
            GeneralExceptionHandler::register($handler);
        });
    }

    public function boot(): void
    {
        // ビューの名前空間(例:general::errors.not-found)
        $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'general');
    }
}

動作確認のためのテスト(Feature)

PHPUnit 11 アトリビュート & Laravel 11 前提。
HTML と JSON の両ケースを確認します。

<?php declare(strict_types=1);

namespace Tool\General\tests\Feature\Exceptions;

use Tests\TestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Small;
use Tool\General\Domain\Exceptions\SpotNotFoundException;
use Illuminate\Support\Facades\Route;

#[Group('exception')]
#[Small]
final class GeneralExceptionHandlerTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // テスト用ルート:例外を投げるだけ
        Route::get('/__throw-spot-not-found', function () {
            throw new SpotNotFoundException(103);
        })->name('throw.spot');
    }

    public function test_html_request_returns_404_view(): void
    {
        $res = $this->get('/__throw-spot-not-found');

        $res->assertStatus(404);
        $res->assertSee('お探しの事業所が見つかりません');
        $res->assertSee('事業所一覧に戻る');
    }

    public function test_api_request_returns_404_json(): void
    {
        $res = $this->getJson('/__throw-spot-not-found');

        $res->assertStatus(404);
        $res->assertJsonFragment([
            'message' => 'Spot not found: 103',
        ]);
    }
}

まとめ

「アプリの Handler を乗っ取らず、パッケージ側からやさしく拡張」が今回の肝でした。
モジュール固有の例外=固有の文言・導線で返せると、UXも運用もグッと良くなります。
まずは NotFound 系から最小構成で入れて、必要になったら順次足していくのがおすすめです。

今回は以上です!