Laravel 例外処理・エラーハンドリング完全ガイド|bootstrap/app.php設定・カスタム例外とSlack通知設計

Laravel入門実装・応用テクニック

Laravelアプリケーション開発において、避けて通れない最重要テーマが例外処理とエラーハンドリングです。適切なエラーハンドリングが実装されていないシステムでは、予期せぬ障害発生時に白い画面(エラーページ)が表示されたり、セキュリティ上危険なスタックトレースがユーザーに露出したり、重大な障害の検知が遅れてデータ不整合を招く危険性があります。

特にLaravel 11およびLaravel 12では、長年フレームワークの標準構成として親しまれてきた app/Exceptions/Handler.php が廃止され、すべての例外ハンドリング設定が bootstrap/app.php の withExceptions() クロージャ内に集約されるという大幅なアーキテクチャの刷新が行われました。

本記事では、最新のLaravel仕様に完全準拠し、例外処理の基礎概念から bootstrap/app.php の設定、カスタム例外クラスの自作、REST APIにおけるエラーレスポンスの統一、Slack連携による本番障害通知、Pest/PHPUnitによる自動テスト手法までを体系的に徹底解説します。


  1. Laravelにおける例外処理の全体像と使い分け早見表
    1. 例外処理手法の使い分け早見表(try-catch / abortヘルパー / カスタム例外 / グローバルハンドラ)
    2. Laravel 11/12における重要変更点(Handler.phpの廃止とbootstrap/app.phpへの移行)
  2. bootstrap/app.php でのグローバル例外ハンドリング設計
    1. withExceptions() による例外捕捉の基本構文
    2. 特定の例外をレンダリングカスタマイズ(renderable / render)
    3. ログ出力をスキップする例外の指定(dontReport / stopIgnoring)
    4. HTTPステータスコード別のカスタムエラー画面表示(404, 500, 403)
  3. カスタム例外(Custom Exception)の実装パターンとベストプラクティス
    1. php artisan make:exception コマンドによる例外クラス作成
    2. ビジネスロジック例外の定義と意味のあるエラーメッセージ設計
    3. 例外クラス自身に report() と render() を持たせる自己完結型設計
  4. REST API開発におけるエラーレスポンスの一元化設計
    1. ValidationException(422)のレスポンスフォーマット統一
    2. ModelNotFoundException(404)および AuthenticationException(401)のJSON化
    3. フロントエンド(React / Vue / Next.js)が扱いやすい一貫したJSON構造の定義
  5. 本番運用における例外監視・外部通知の設計
    1. 重大例外(500系)発生時のSlack / Discord Webhook通知の実装
    2. Sentry / Bugsnag等のエラー監視SaaSとのインテグレーション
    3. 機密情報(パスワード・個人情報)のログマスキング対策
  6. 例外処理のユニットテスト・Featureテスト(Pest / PHPUnit)
    1. 指定の例外がスローされることを検証するテストコード
      1. Pest によるテスト例
      2. PHPUnit によるテスト例
    2. APIエラーレスポンスのステータスコードとJSON構造のアサーション
  7. よくある質問(FAQ)
    1. Q1: Laravel 10から11/12にアップグレードした際、既存の Handler.php は削除すべきですか?
    2. Q2: try-catch とカスタム例外の render() はどちらを優先すべきですか?
    3. Q3: abort(403) と Gate/Policy の例外はどう使い分けますか?
    4. Q4: APIリクエストなのにHTMLエラー画面が返ってきてしまう原因は何ですか?
  8. まとめと次のステップ
    1. 例外処理設計の実務チェックリスト
    2. 関連トピッククラスター記事一覧
  9. 関連記事

Laravelにおける例外処理の全体像と使い分け早見表

Laravelにおける例外処理は、アプリケーションのレイヤー(コントローラー、サービス層、ドメインモデル、グローバルミドルウェア)や処理の目的に応じて複数のアプローチが存在します。場当たり的に try-catch を散乱させるのではなく、責務に応じた適切な使い分けを行うことが堅牢な設計の第一歩です。

例外処理手法の使い分け早見表(try-catch / abortヘルパー / カスタム例外 / グローバルハンドラ)

Laravelで利用可能な代表的な例外処理手法と、それぞれの特徴・推奨される利用シーンは以下の通りです。

処理手法 主な特徴・書き方 適用すべきユースケース 注意点・非推奨な使い方
try-catch 構文 PHPネイティブ構文。発生した例外をその場で捕捉して代替処理を実行。 外部API呼び出し失敗時のフォールバック処理、DBトランザクションの手動ロールバックなど局所的なリカバリ。 例外を握りつぶして空のcatchにすること。コントローラーの全アクションにベタ書きすること。
abort / abort_if / abort_unless 指定条件で即座に HttpException(403, 404, 500等)を送出するヘルパー。 認可チェック失敗時の403 Forbidden、特定リソース未検出時の404 Not Foundなど素早い脱出。 複雑なエラーペイロードやビジネス固有のコンテキストをフロントエンドに返却したいケース。
カスタム例外(Custom Exception) php artisan make:exception で作成するドメイン固有の例外クラス。自己完結型(report/render内包)が可能。 「ポイント残高不足」「契約プラン制限超過」「在庫引き当て失敗」など、業務ロジックの破綻を表現。 単なる構文エラーや標準例外(InvalidArgumentException等)で十分な場合に過剰に乱立させること。
グローバルハンドラ(bootstrap/app.php) アプリケーション全体で発生した未捕捉の例外を一元管理・ログ出力・JSON化。 API全体の未捕捉例外を共通フォーマットに整形、重大エラーのSlack/Sentry通知、監査ログ記録。 個別コントローラー特有の細かなビジネスフォールバックをグローバルに書き連ねて肥大化させること。

Laravelには手軽にHTTP例外を投げられる便利なヘルパー関数群が用意されています。条件分岐をシンプルに記述したい場合は、以下の単体リファレンス記事も合わせてご確認ください。

Laravel 11/12における重要変更点(Handler.phpの廃止とbootstrap/app.phpへの移行)

Laravel 10以前のバージョンでは、例外ハンドリングのカスタマイズは app/Exceptions/Handler.php にある register() メソッド内に $this->reportable() や $this->renderable() を記述するのが標準的でした。

しかし、Laravel 11以降の最小構成(Slim Skeleton)への進化に伴い、app/Exceptions/Handler.php はデフォルトのディレクトリ構成から完全に削除されました。現在では、すべてのグローバル例外設定が bootstrap/app.php の withExceptions() メソッド内に定義されます。

// Laravel 11/12 における bootstrap/app.php の構造例
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        // ミドルウェア設定
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        // ★ここにすべての例外ハンドリング設定を集約する
    })->create();

この変更により、アプリケーションのエントリポイントで設定が一元管理できるようになり、ルーティングやミドルウェアと同様のフルエントな記法で設定が可能になりました。次章では、この bootstrap/app.php 内での具体的なカスタマイズ方法を詳しく解説します。


bootstrap/app.php でのグローバル例外ハンドリング設計

bootstrap/app.php の withExceptions() クロージャに渡される Illuminate\Foundation\Configuration\Exceptions インスタンスを使用することで、アプリケーション全体の例外ハンドリングを細かく制御できます。

withExceptions() による例外捕捉の基本構文

未捕捉の例外をログに記録する(レポートする)処理や、外部通知に送る処理を定義するには report() や reportable() を使用します。

use Illuminate\Foundation\Configuration\Exceptions;
use Throwable;
use App\Exceptions\PaymentGatewayException;
use Illuminate\Support\Facades\Log;

->withExceptions(function (Exceptions $exceptions): void {
    // 特定の例外が発生した際のカスタムログ出力
    $exceptions->report(function (PaymentGatewayException $e): void {
        Log::channel('payment_alerts')->error('決済ゲートウェイ通信エラーが発生しました', [
            'order_id' => $e->getOrderId(),
            'error_code' => $e->getCode(),
            'message' => $e->getMessage(),
        ]);
    });

    // reportable を使用した条件付きレポート制御(false を返すと以降の標準レポートを抑制)
    $exceptions->reportable(function (PaymentGatewayException $e): bool {
        if ($e->isSafeToIgnore()) {
            return false; // 標準ログへの出力を停止
        }
        return true;
    });
})

特定の例外をレンダリングカスタマイズ(renderable / render)

例外が発生した際、ユーザーやクライアントに返却するHTTPレスポンスをカスタマイズするには render() メソッドを使用します。コントローラーで例外をキャッチし忘れた場合でも、ここで適切なJSONやHTMLレスポンスに変換できます。

use App\Exceptions\InsufficientBalanceException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (InsufficientBalanceException $e, Request $request): Response {
        // APIリクエストまたはJSONを期待している場合
        if ($request->expectsJson()) {
            return response()->json([
                'status' => 'error',
                'error' => [
                    'code' => 'INSUFFICIENT_BALANCE',
                    'message' => $e->getMessage(),
                    'required_amount' => $e->getRequiredAmount(),
                    'current_balance' => $e->getCurrentBalance(),
                ],
            ], 422);
        }

        // Web画面リクエストの場合は専用のエラービューを表示
        return response()->view('errors.balance', [
            'exception' => $e,
        ], 422);
    });
})

ログ出力をスキップする例外の指定(dontReport / stopIgnoring)

バリデーションエラー(ValidationException)や404エラー(NotFoundHttpException)など、日常的に頻発する通常のHTTPエラーはデフォルトでログ出力を無視(ignore)するよう設定されています。

自作の例外をログ出力から除外したい場合は dontReport() を使用し、逆にフレームワークがデフォルトで除外している例外をログに残したい場合は stopIgnoring() を使用します。

use App\Exceptions\ClientInputWarningException;
use Symfony\Component\HttpKernel\Exception\HttpException;

->withExceptions(function (Exceptions $exceptions): void {
    // 指定した例外クラスをログ記録対象から除外
    $exceptions->dontReport([
        ClientInputWarningException::class,
    ]);

    // デフォルトで除外されているHTTP例外(404や403など)をあえてログに残したい場合
    $exceptions->stopIgnoring(HttpException::class);
})

HTTPステータスコード別のカスタムエラー画面表示(404, 500, 403)

Laravelでは、resources/views/errors/ ディレクトリにHTTPステータスコードと同名のBladeファイル(404.blade.php, 403.blade.php, 500.blade.php)を配置するだけで、標準のエラー画面を簡単に上書きできます。

さらに bootstrap/app.php の respond() メソッドを利用することで、ステータスコード別のレスポンス生成ロジックをプログラマティックに制御することも可能です。

use Symfony\Component\HttpFoundation\Response;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->respond(function (Response $response, Throwable $e, Request $request): Response {
        // 本番環境で500エラーが発生した場合に特定のセキュリティヘッダーを追加
        if ($response->getStatusCode() === 500 && app()->isProduction()) {
            $response->headers->set('X-Incident-Track-ID', (string) str()->uuid());
        }

        return $response;
    });
})

カスタム例外(Custom Exception)の実装パターンとベストプラクティス

ビジネスロジックが複雑化するにつれて、汎用の Exception やHTTPヘルパーだけでは表現しきれないドメイン固有のエラー条件が発生します。そこで活躍するのがカスタム例外(Custom Exception)です。

php artisan make:exception コマンドによる例外クラス作成

Laravelでカスタム例外を作成するには、Artisanコマンドを実行します。

# 基本的なカスタム例外クラスの生成
php artisan make:exception InsufficientInventoryException

# レポート・レンダリングメソッドを含めたひな形の生成
php artisan make:exception InsufficientInventoryException --render --report

生成されたクラスは app/Exceptions/ ディレクトリに配置されます。

ビジネスロジック例外の定義と意味のあるエラーメッセージ設計

優れた例外クラスは、単に「エラーが起きた」という事実だけでなく、「なぜ失敗したのか」「どのような文脈(Context)であったか」の情報を型安全に保持します。

<?php

namespace App\Exceptions;

use Exception;

class InsufficientInventoryException extends Exception
{
    public function __construct(
        protected int $productId,
        protected int $requestedQuantity,
        protected int $availableStock
    ) {
        $message = "商品ID: {$productId} の在庫が不足しています。(要求数: {$requestedQuantity}, 現在庫: {$availableStock})";
        parent::__construct($message, 422);
    }

    public function getProductId(): int
    {
        return $this->productId;
    }

    public function getRequestedQuantity(): int
    {
        return $this->requestedQuantity;
    }

    public function getAvailableStock(): int
    {
        return $this->availableStock;
    }

    /**
     * ログ出力時に付加されるコンテキストデータ
     */
    public function context(): array
    {
        return [
            'product_id' => $this->productId,
            'requested_quantity' => $this->requestedQuantity,
            'available_stock' => $this->availableStock,
        ];
    }
}

例外クラス内に context() メソッドを定義しておくと、Laravelのロガーが例外を記録する際に自動的に配列情報を抽出してログコンテキストとして付与してくれます。

【重要:DBトランザクション連携におけるカスタム例外】
データベースの更新処理中にカスタム例外をスローすると、Laravelの DB::transaction() は自動的にすべての変更をロールバックしてデータの不整合を防止します。トランザクション制御やデッドロック時の自動リトライ設計については、以下の決定版ガイドも必ず押さえておきましょう。
👉 Laravel DBトランザクション完全ガイド|デッドロック自動リトライと手動ロールバック制御

例外クラス自身に report() と render() を持たせる自己完結型設計

すべての例外ハンドリング設定を bootstrap/app.php に集約していくと、大規模アプリケーションでは bootstrap/app.php が肥大化して保守性が悪化します。

Laravelでは、例外クラス自身に report() メソッドおよび render() メソッドを定義することで、その例外の処理方法を自己完結(Self-contained)させることが可能です。

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;

class ExpiredCouponException extends Exception
{
    public function __construct(protected string $couponCode)
    {
        parent::__construct("指定されたクーポン「{$couponCode}」は有効期限が切れています。", 400);
    }

    /**
     * 例外のカスタムレポート処理
     * false を返すと、以降の標準ロギングを抑制する
     */
    public function report(): bool
    {
        Log::warning('期限切れクーポンの適用試行', [
            'coupon_code' => $this->couponCode,
            'user_id' => auth()->id(),
        ]);

        return false;
    }

    /**
     * 例外発生時のレスポンスレンダリング
     */
    public function render(Request $request): JsonResponse|Response
    {
        if ($request->expectsJson()) {
            return response()->json([
                'status' => 'error',
                'error' => [
                    'code' => 'COUPON_EXPIRED',
                    'message' => $this->getMessage(),
                    'coupon_code' => $this->couponCode,
                ],
            ], 400);
        }

        return back()->withErrors(['coupon' => $this->getMessage()]);
    }
}

このように例外クラス自身にハンドリングを持たせることで、bootstrap/app.php をクリーンに保ち、ドメイン駆動設計(DDD)やモジュラーモノリスに適した高い凝集度を実現できます。


REST API開発におけるエラーレスポンスの一元化設計

SPA(Single Page Application)やモバイルアプリ、マイクロサービスとの連携において最もトラブルになりやすいのが、エラーレスポンスのフォーマット不統一です。

例えば、バリデーションエラーは {"message": "...", "errors": {...}} なのに、認証エラーは {"message": "Unauthenticated."}、404エラーはHTMLの404ページが返ってくるといった状態では、フロントエンド(React / Vue / Next.js)側のエラーハンドリング処理が複雑怪奇になってしまいます。

ValidationException(422)のレスポンスフォーマット統一

Laravel標準の ValidationException は便利な構造を持っていますが、プロジェクト全体の共通レスポンス構造(例: status, error, meta)に適合させるため、bootstrap/app.php で統一フォーマットに変換します。

use Illuminate\Validation\ValidationException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (ValidationException $e, Request $request): ?Response {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'VALIDATION_FAILED',
                    'message' => '入力データに不備があります。内容をご確認ください。',
                    'details' => $e->errors(),
                ],
            ], 422);
        }

        return null; // null を返すとLaravel標準のHTMLリダイレクト&エラーセッション処理に委任
    });
})

ModelNotFoundException(404)および AuthenticationException(401)のJSON化

データベースのEloquent検索で見つからなかった場合の ModelNotFoundException や、未ログイン時の AuthenticationException も一元捕捉して整形します。

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Auth\AuthenticationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

->withExceptions(function (Exceptions $exceptions): void {
    // 404 リソース未検出
    $exceptions->render(function (ModelNotFoundException|NotFoundHttpException $e, Request $request): ?Response {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'RESOURCE_NOT_FOUND',
                    'message' => '指定されたリソースが見つかりませんでした。',
                    'details' => null,
                ],
            ], 404);
        }
        return null;
    });

    // 401 認証エラー
    $exceptions->render(function (AuthenticationException $e, Request $request): ?Response {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'UNAUTHENTICATED',
                    'message' => '認証情報が無効であるか、ログインの有効期限が切れています。',
                    'details' => null,
                ],
            ], 401);
        }
        return null;
    });

    // 403 権限不足
    $exceptions->render(function (AccessDeniedHttpException $e, Request $request): ?Response {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'FORBIDDEN_ACCESS',
                    'message' => 'この操作を実行する権限がありません。',
                    'details' => null,
                ],
            ], 403);
        }
        return null;
    });
})

フロントエンド(React / Vue / Next.js)が扱いやすい一貫したJSON構造の定義

フロントエンド開発者がTypeScriptで厳密な型定義を行えるよう、正常系・異常系ともにトップレベルのスキーマ構造を統一することが推奨されます。

// TypeScript での統一APIエラーレスポンス型定義例
export interface ApiErrorResponse {
  success: false;
  error: {
    code: string;               // 機械可読なエラーコード (例: "VALIDATION_FAILED", "RESOURCE_NOT_FOUND")
    message: string;            // ユーザーまたは開発者に向けた説明文
    details: Record<string, string[]> | null; // バリデーションエラー詳細など
  };
}

さらに、Laravel 11以降では shouldRenderJsonWhen() メソッドを活用することで、特定条件下(例:リクエストパスが api/* の場合)で強制的にJSONレスポンスを返却するよう指定できます。

->withExceptions(function (Exceptions $exceptions): void {
    // APIルート配下のリクエストであれば、Acceptヘッダーが欠落していても常にJSONでレンダリング
    $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e): bool {
        return $request->is('api/*') || $request->expectsJson();
    });
})

本番運用における例外監視・外部通知の設計

本番運用において最も恐ろしいのは、「ユーザーが500エラーで画面を閉じているのに、開発チームが誰も気づいていない」というサイレント障害です。これを防ぐため、重大例外が発生した瞬間にチャットツール(SlackやDiscord)へ即時アラートを飛ばす仕組みと、エラートラッキングSaaSの導入が不可欠です。

重大例外(500系)発生時のSlack / Discord Webhook通知の実装

bootstrap/app.php の reportable() を使用して、未処理のサーバーエラーをSlack Incoming Webhook経由で通知する実装例です。

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

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->reportable(function (Throwable $e): void {
        // 本番環境かつ、HTTP 500系の重大エラーのみを通知対象とする
        if (!app()->isProduction()) {
            return;
        }

        // 通常のクライアント起因エラー(404や422など)はスキップ
        if ($exceptions->shouldntReport($e)) {
            return;
        }

        $webhookUrl = config('services.slack.webhook_url');
        if (empty($webhookUrl)) {
            return;
        }

        try {
            Http::timeout(3)->post($webhookUrl, [
                'text' => '🚨 *本番環境で重大な例外が発生しました*',
                'attachments' => [
                    [
                        'color' => '#E01E5A',
                        'fields' => [
                            [
                                'title' => '例外クラス',
                                'value' => get_class($e),
                                'short' => true,
                            ],
                            [
                                'title' => 'リクエストURL',
                                'value' => request()->fullUrl(),
                                'short' => true,
                            ],
                            [
                                'title' => 'エラーメッセージ',
                                'value' => mb_strimwidth($e->getMessage(), 0, 300, '...'),
                                'short' => false,
                            ],
                            [
                                'title' => '発生箇所',
                                'value' => $e->getFile() . ':' . $e->getLine(),
                                'short' => false,
                            ],
                        ],
                        'ts' => now()->timestamp,
                    ],
                ],
            ]);
        } catch (Throwable $notificationError) {
            // Webhook送信自体の失敗で本体の処理をクラッシュさせない
            Log::emergency('Slack例外通知の送信に失敗しました: ' . $notificationError->getMessage());
        }
    });
})

【運用上の注意:通知スパム対策(Throttling)】
データベースダウンなどで1秒間に数十件〜数百件の例外が発生した場合、Slack Webhookのレート制限に引っかかるだけでなく、開発チャンネルが通知で埋め尽くされてしまいます。実務では Cache::has() を使って「同じ例外メッセージは10分間に1回だけ通知する」といったレート制限(デバウンス)を設けるのがベストプラクティスです。

Sentry / Bugsnag等のエラー監視SaaSとのインテグレーション

チャット通知は「緊急検知」には適していますが、「発生頻度の集計」「スタックトレースの詳細分析」「修正コミットとの紐付け」を行うには、専用のエラー監視SaaS(SentryやBugsnag)が圧倒的に便利です。

Laravel公式サポートのある Sentry を導入する場合の手順は非常にシンプルです。

# Sentry SDK のインストール
composer require sentry/sentry-laravel

# 設定ファイルのパブリッシュ
php artisan sentry:publish --dsn=https://examplePublicKey@o0.ingest.sentry.io/0

Laravel 11/12 では、bootstrap/app.php の withExceptions() 内で Sentry のハンドラーを呼び出すだけで連携が完了します。

use Sentry\Laravel\Integration;

->withExceptions(function (Exceptions $exceptions): void {
    // Sentry への自動エラーキャプチャ
    Integration::handles($exceptions);
})

機密情報(パスワード・個人情報)のログマスキング対策

障害調査のためにリクエストパラメータや環境変数をログに出力する際、パスワードやクレジットカード番号、APIシークレットが平文のまま記録されてしまうと重大なセキュリティ事故になります。

Laravelでは、config/logging.php や bootstrap/app.php のコンテキスト設定、および Illuminate\Http\Request のマスキング機能を組み合わせて対策します。

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    // ログコンテキストから機密パラメータを自動置換
    Log::shareContext([
        'environment' => app()->environment(),
    ]);
}

また、.env の APP_DEBUG=false は本番環境で徹底し、外部ユーザーに対して詳細な環境情報(データベースホスト名や内部パス)が絶対に開示されないように確認してください。


例外処理のユニットテスト・Featureテスト(Pest / PHPUnit)

例外処理が正しく動作しているかどうかは、手動ブラウザ操作だけでなく自動テストで担保することが不可欠です。Laravelでは Pest または PHPUnit を用いて、例外のスローやAPIエラーレスポンスの構造を容易に検証できます。

指定の例外がスローされることを検証するテストコード

サービス層やドメインロジックのユニットテストでは、特定の入力や状態において期待通りのカスタム例外がスローされるかをテストします。

Pest によるテスト例

// tests/Unit/OrderServiceTest.php
use App\Services\OrderService;
use App\Exceptions\InsufficientInventoryException;

test('在庫が不足している場合は InsufficientInventoryException がスローされる', function () {
    $service = new OrderService();

    // 在庫ゼロの商品に対して注文処理を実行
    expect(fn () => $service->order(productId: 999, quantity: 5))
        ->toThrow(InsufficientInventoryException::class, '在庫が不足しています');
});

PHPUnit によるテスト例

// tests/Unit/OrderServiceTest.php
namespace Tests\Unit;

use Tests\TestCase;
use App\Services\OrderService;
use App\Exceptions\InsufficientInventoryException;

class OrderServiceTest extends TestCase
{
    public function test_throws_exception_when_inventory_is_insufficient(): void
    {
        $this->expectException(InsufficientInventoryException::class);
        $this->expectExceptionMessage('在庫が不足しています');

        $service = new OrderService();
        $service->order(productId: 999, quantity: 5);
    }
}

APIエラーレスポンスのステータスコードとJSON構造のアサーション

Featureテストでは、HTTPリクエストを送信して適切なエラーレスポンスが返されるかをアサートします。

// tests/Feature/Api/UserRegistrationTest.php
use function Pest\Laravel\postJson;

test('バリデーションエラー時に統一フォーマットの422 JSONが返却される', function () {
    $response = postJson('/api/register', [
        'email' => 'invalid-email-format',
        // password を意図的に省略
    ]);

    $response->assertStatus(422)
        ->assertJsonStructure([
            'success',
            'error' => [
                'code',
                'message',
                'details' => [
                    'email',
                    'password',
                ],
            ],
        ])
        ->assertJsonPath('success', false)
        ->assertJsonPath('error.code', 'VALIDATION_FAILED');
});

test('未認証ユーザーのアクセス時に401 JSONが返却される', function () {
    $response = postJson('/api/user/profile');

    $response->assertStatus(401)
        ->assertJsonPath('error.code', 'UNAUTHENTICATED');
});

【テスト作成時のTips:withoutExceptionHandling() の使い分け】
開発中に「なぜテストが500エラーで落ちているのか」原因を特定したい場合は、$this->withoutExceptionHandling() を呼び出すとLaravelの例外捕捉が無効化され、生のスタックトレースがターミナルに表示されます。一方、エラーハンドラーの動作自体をテストする場合は、このメソッドを外してフレームワークに例外をハンドリングさせましょう。


よくある質問(FAQ)

Q1: Laravel 10から11/12にアップグレードした際、既存の Handler.php は削除すべきですか?

必須ではありません。Laravel 11/12 にアップグレードした場合でも、app/Exceptions/Handler.php が残っていれば後方互換性によりそのまま動作します。ただし、新規プロジェクトや今後のメンテナンス性を考慮すると、徐々に bootstrap/app.php の withExceptions() スタイルに移行していくことが推奨されます。

Q2: try-catch とカスタム例外の render() はどちらを優先すべきですか?

「その場で代替処理を行って処理を正常系に戻せるか」で判断します。例えば「キャッシュから取得できなければDBにフォールバックする」場合は局所的な try-catch が適切です。一方で「在庫不足でこれ以上業務を継続できない」ような回復不能なビジネスエラーは、カスタム例外をスローして例外自身の render() やグローバルハンドラに任せるのが最もコードをシンプルに保てます。

Q3: abort(403) と Gate/Policy の例外はどう使い分けますか?

コントローラーやアクション内で権限判定を行う場合は、Policyの $this->authorize() メソッドを使用するのが標準的です。権限がない場合は自動的に AuthorizationException(403)がスローされます。一方、ルートのクロージャ内や軽量なミドルウェア内で素早く弾きたい場合は abort_if(! $user->isAdmin(), 403) ヘルパーを使うとコードが簡潔になります。

Q4: APIリクエストなのにHTMLエラー画面が返ってきてしまう原因は何ですか?

クライアントからのHTTPリクエストヘッダーに Accept: application/json が含まれていないことが原因の大多数です。Laravelはデフォルトで $request->expectsJson() を見てレスポンス形式を決定するため、AcceptヘッダーがないとブラウザからのWebリクエストと判定されます。本記事で解説した bootstrap/app.php の shouldRenderJsonWhen() を設定することで、api/* へのアクセスを強制的にJSON化できます。


まとめと次のステップ

例外処理設計の実務チェックリスト

  • グローバル設定の集約: bootstrap/app.php の withExceptions() で未捕捉エラーを漏れなくハンドリングしているか?
  • APIレスポンスの統一: 404, 401, 422, 500 等の異常系スキーマがすべて一貫したJSON構造になっているか?
  • カスタム例外の適切な粒度: 業務ロジックの失敗を表現する例外クラスに十分なコンテキスト情報を持たせているか?
  • 本番監視と通知: 500エラー発生時のSlack/Sentry連携、および通知スパム防止のレート制限を行っているか?
  • セキュリティ: APP_DEBUG=false の徹底と、ログ出力時の機密情報マスキングが行われているか?
  • 自動テストの整備: Pest/PHPUnitで例外スローおよび異常系APIレスポンスを検証しているか?

関連トピッククラスター記事一覧

Laravelの例外処理をマスターしたら、併せて以下の関連リファレンスやデータ整合性の設計ガイドも習得し、より堅牢なLaravelアプリケーションを構築しましょう。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント