Laravel APIエラーハンドリング設計で実務レビューされやすいポイント

Laravel APIエラーハンドリング設計で例外ハンドラ、HTTPステータス、JSONレスポンスを整理する図

Laravel APIのエラー設計は例外を返すだけでは足りない

Laravel APIエラーハンドリングでまず整理したいのは、例外の発生位置です。次に、JSONレスポンスへ変換する場所を決めます。Controllerごとにtry-catchを書くと、短期的には動きます。しかし、HTTPステータス、エラー形式、ログ出力がすぐにばらつきます。

結論から言うと、LaravelのAPIエラー設計では、責務を分けて考えます。FormRequest、業務例外、例外ハンドラ、ログ、レスポンス形式を混ぜないことが重要です。入力値の不正はFormRequestに寄せます。一方で、締め済みデータの更新は業務ルール違反です。在庫不足も同じです。これらはアプリケーション側の例外として表現します。

フレームワーク任せにする範囲を決める

そのうえで、Laravelの例外ハンドリング機構でJSONレスポンスへ変換します。Laravel公式のError Handlingでも、例外のreportとrenderが説明されています。アプリケーション側で管理できる仕組みです。つまり、フレームワーク任せにする部分を決めます。さらに、プロジェクトで設計する部分も切り分けます。

この記事では、LaravelでREST APIを作るバックエンドエンジニア向けに整理します。APIエラーハンドリング設計でレビューされやすいポイントを扱います。単なるステータスコード表ではありません。実装位置、レスポンス形式、FormRequest、ログ、レビュー観点まで扱います。

Laravel APIエラーハンドリングで最初に決める責務

まず、エラーの種類ごとに責務を分けます。Laravelでは便利な仕組みが多くあります。Controller、FormRequest、Service、例外ハンドラのどこでもエラーを扱えてしまいます。そのため、先に分担を決めます。決めないと実装者ごとに判断が割れます。

エラーの種類主な実装位置レスポンスの考え方
入力値の不正FormRequest422で項目単位のerrorsを返す
認証エラー認証ミドルウェア401でログインやトークン更新へ誘導する
認可エラーPolicy、Gate、Service403で権限不足を返す
業務ルール違反Service、UseCase、Domain層409や422などを業務コード付きで返す
対象データなしRepository、Service、Route Model Binding404か業務エラーかを仕様で決める
想定外の障害例外ハンドラ500で詳細を隠し、ログで追跡する

入力チェックと業務ルールを分ける

この分担があると、レビューで迷いにくくなります。「この例外はControllerで握るべきか」という議論を減らせます。例えば、FormRequestで扱うべき入力チェックがあります。それをService内で都度書くと、重複や漏れが起きやすくなります。

一方で、FormRequestに業務ルールを詰め込みすぎるのも危険です。例えば「注文が締め済みか」は、単なる入力形式ではありません。「ユーザーがこの取引先を操作できるか」も業務判断です。画面以外のバッチでも再利用する可能性があります。外部連携でも同じです。そのため、ServiceやUseCase側に寄せるほうが保守しやすいです。

FormRequestのバリデーションエラーはAPI仕様としてそろえる

LaravelのFormRequestは、API入力チェックを整理するうえで有効です。Laravel公式のValidationドキュメントでも、FormRequestが説明されています。バリデーションやメッセージのカスタマイズを扱える仕組みです。

ただし、実務では「検証できるか」だけでは足りません。フロントエンドが扱いやすいJSONレスポンスになっているか。エラー項目名がリクエスト項目名と対応しているか。多言語化や画面表示の責務をどこまでAPIが持つか。ここまで含めてAPIレビューの対象になります。

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1'],
        ];
    }

    public function messages(): array
    {
        return [
            'items.min' => '注文明細を1件以上指定してください。',
        ];
    }
}

この例では、必須、型、存在チェックをFormRequestに寄せています。配列件数も入口で確認します。まずは入力の形を入口で落とします。そうすると、Service側は業務判断に集中できます。

なお、バリデーションエラーのHTTPステータスはプロジェクトで統一します。LaravelではJSONリクエストに対して、バリデーションエラーをJSONで返せます。ただし、フロントエンドが期待する形式と一致するとは限りません。そのため、項目名、message、errors、codeの構造を決めます。API仕様として明文化することが重要です。

HTTPステータスはLaravelの都合ではなく利用者の判断で選ぶ

次に、HTTPステータスコードを決めます。Laravelでは例外やヘルパーで簡単にJSONレスポンスを返せます。しかし、ステータスはフレームワークの都合で決めません。API利用者が次に何を判断できるかで決めます。

ステータスLaravel APIでの例レビュー観点
400JSON形式が不正、クエリ指定が仕様外422の入力検証と混ぜすぎない
401未認証、トークンなし、トークン期限切れ再ログインや再発行の導線があるか
403認証済みだが対象操作の権限がない存在確認の手掛かりを返しすぎていないか
404指定IDのリソースが存在しない業務上は非表示にすべきリソースも含めて扱うか
409楽観ロック、二重申請、状態競合再取得や再操作で解決できるか伝える
422FormRequestのバリデーションエラー項目単位で修正できる情報があるか
500想定外のサーバーエラー内部例外やSQLを返していないか
503外部サービス停止、メンテナンスリトライ可能性をどう伝えるか

HTTPステータスで利用者の次の行動を分ける

例えば、締め済み注文の更新失敗を500で返すのは不適切です。これはサーバー障害ではなく、業務状態による拒否だからです。一方で、DB接続失敗の詳細を400系で返すのも危険です。利用者が修正できる問題ではなく、内部の障害だからです。

また、すべてを200で返す設計も避けたいところです。bodyのsuccessだけで判定すると、共通処理が弱くなります。HTTPクライアント、監視、リトライ制御がステータスコードを利用できません。フロントエンドの共通ハンドラも使いにくくなります。つまり、HTTPステータスは単なる飾りではありません。

JSONレスポンス形式は成功系よりエラー系で差が出る

APIレスポンス形式は、成功時よりエラー時に設計差が出ます。成功レスポンスは画面ごとに形が違っても扱えます。しかし、エラー形式がバラバラだと困ります。フロントエンドの共通エラーハンドラや監視連携が作りにくくなります。

{
  "code": "ORDER_ALREADY_CLOSED",
  "message": "締め済みの注文は変更できません。",
  "errors": [],
  "trace_id": "01JZ3K7K2C8M6Q5Y9P2H4A1B0C"
}

業務エラーでは、画面表示用のmessageだけでは足りません。機械判定用のcodeを持たせると扱いやすくなります。例えば、`ORDER_ALREADY_CLOSED` のようなコードを使います。そうすると、フロントエンド側で表示や再取得を分岐できます。

一方で、バリデーションエラーは項目単位のerrorsが重要です。画面のどの入力欄にエラーを表示するかを決めるためです。そのため、業務エラーと入力エラーを同じ形に寄せすぎないようにします。寄せすぎると、かえって使いにくくなる場合があります。

{
  "code": "VALIDATION_FAILED",
  "message": "入力内容を確認してください。",
  "errors": {
    "items.0.quantity": [
      "数量には1以上の整数を指定してください。"
    ]
  },
  "trace_id": "01JZ3K8H0E7R4V2N8A6X5M9Q1D"
}

なお、標準化を強く意識する場合があります。その場合は、RFC 9457 Problem Details for HTTP APIsの考え方も参考になります。ただし、既存システムやフロントエンドの事情もあります。採用するなら、既存レスポンスとの互換性を見ます。項目エラーの表現やAPI仕様書への反映も含めて判断します。

例外ハンドラでは業務例外をJSONへ変換する

Laravel APIでは、Controllerごとにtry-catchを書くより、例外ハンドラで変換をそろえます。そのほうが保守しやすいです。特にLaravel 11以降では、`bootstrap/app.php` の `withExceptions` を使います。例外のreportやrenderを設定する流れが一般的です。

use App\Exceptions\ApiBusinessException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

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) {
        //
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->render(function (ApiBusinessException $e, Request $request) {
            if (! $request->expectsJson()) {
                return null;
            }

            return response()->json([
                'code' => $e->errorCode(),
                'message' => $e->getMessage(),
                'errors' => [],
                'trace_id' => $request->attributes->get('trace_id'),
            ], $e->statusCode());
        });
    })
    ->create();

このように変換位置をそろえると、Service側は例外で表現できます。「何が起きたか」を内側で扱う形です。そして、HTTPやJSONの都合は外側で扱えます。その結果、Controllerが薄くなります。エラーレスポンスの形式も統一しやすくなります。

ただし、すべての例外を独自例外に包み直す必要はありません。例えば、認証、認可、モデル未検出があります。バリデーションもLaravelが標準で扱う例外があります。独自化するのは、業務コードや画面制御が必要な領域に絞ります。そのほうが運用しやすいです。

業務例外はHTTPに寄せすぎない

業務例外を設計するときは、内部の意味とHTTPレスポンスを結合しすぎないほうが安全です。例えば、`OrderAlreadyClosedException` は業務上の意味です。一方で、それを409にするか422にするかはAPI仕様の判断です。

namespace App\Exceptions;

use RuntimeException;

class ApiBusinessException extends RuntimeException
{
    public function __construct(
        private readonly string $code,
        string $message,
        private readonly int $status = 422,
    ) {
        parent::__construct($message);
    }

    public function errorCode(): string
    {
        return $this->code;
    }

    public function statusCode(): int
    {
        return $this->status;
    }
}

もちろん、プロジェクト規模によってはここまで抽象化しない判断もあります。小さなAPIでは、独自例外が増えすぎると追跡しにくくなります。一方で、複数画面から同じ業務処理を呼ぶ場合は価値があります。外部連携から呼ぶ場合も、業務例外を定義しておく価値が高くなります。

Laravel APIレビューで見られやすい失敗例

ここからは、実務レビューで指摘されやすい失敗例を整理します。どれも単体では小さく見えます。しかし、APIが増えるほど保守性に効いてきます。

Controllerで例外を握りつぶしている

Controllerの中で大きなtry-catchを書く実装は危険です。すべて同じJSONで返すと、原因ごとのステータスが分かれません。さらに、ログに必要な情報が残らないこともあります。

そのため、Controllerでは正常系の流れを中心に書きます。業務エラーは例外として投げ、共通の例外ハンドラでレスポンスへ変換します。想定外の例外は無理に握らず、ログと監視で検知できる形にします。

バリデーションと業務ルールが混ざっている

FormRequestに何でも入れると、入力チェックと業務判断が混ざります。例えば「数量は1以上」はFormRequestで扱いやすいです。一方で「この商品は販売停止中なので注文できない」は業務判断です。在庫や商品状態を含むためです。

この境界が曖昧だと、同じ処理を使うときに困ります。CLI、バッチ、外部API連携から呼ぶ場面です。そこで、入力の形はFormRequestに寄せます。業務上の可否はServiceやUseCaseに分けます。

エラーメッセージに内部情報を出している

500系エラーでSQL、テーブル名、スタックトレースを返すのは避けます。外部サービスの詳細レスポンスも同じです。利用者には解決できない情報です。さらに、セキュリティ上の手掛かりになる場合があります。

ただし、情報を隠せばよいわけではありません。運用者が追跡できるように、trace_idやrequest_idを返します。詳細はログ側に残します。ユーザー向けのmessageと運用向けのログは、役割を分けて設計します。

エラーコードが場当たり的に増えている

業務エラーコードは便利です。しかし、命名ルールがないまま増やすと、似たコードが乱立します。例えば、`ORDER_CLOSED` と `CLOSED_ORDER` が同じ意味で使われる場合です。`ORDER_ALREADY_FIXED` まで混ざると、フロントエンド側の分岐も混乱します。

そのため、エラーコードは一覧管理します。ドメイン名、状態、原因が読み取れる命名にします。また、廃止予定のコードや互換性もAPI仕様書に残します。

Laravel APIエラーハンドリングのレビュー観点

最後に、レビューで確認したい観点をチェックリストにします。実装が動くかだけでは不十分です。利用者、運用者、将来の保守担当者が困らないかを見ることが重要です。

  • FormRequestで扱う入力チェックと、Serviceで扱う業務ルールが分かれているか
  • Controllerごとのtry-catchでエラー形式がばらついていないか
  • 401、403、404、409、422、500の使い分けがAPI仕様として説明できるか
  • バリデーションエラーの項目名がフロントエンドの入力項目と対応しているか
  • 業務エラーに機械判定用のcodeがあるか
  • 500系レスポンスで内部例外、SQL、スタックトレースを返していないか
  • trace_idやrequest_idでログを追跡できるか
  • 同じ業務エラーが画面、バッチ、外部API連携で再利用できるか
  • エラーコードとレスポンス例がOpenAPIなどの仕様書に反映されているか

また、APIレビューでは「正しいステータスを返しているか」だけで止めないほうがよいです。なぜなら、現場で困るのはエラー発生後の扱いだからです。画面はどう出すのか。再試行できるのか。ログから原因を追えるのか。ここまで確認すると、レビューの質が上がります。

よくある質問

Laravel APIのバリデーションエラーは400と422のどちらがよいですか?

実務では、入力項目ごとの検証に失敗した場合は422を使う設計が多いです。FormRequestのエラーが代表例です。一方で、JSONとして壊れている場合は400に寄せる判断もあります。必須のヘッダーがない場合や、クエリ指定が仕様外の場合も同じです。重要なのは、プロジェクト内で使い分けを固定することです。

Laravelの例外ハンドラで全部の例外をJSON化すべきですか?

APIルートではJSON形式をそろえるべきです。ただし、すべての例外を同じ業務エラーに変換する必要はありません。認証、認可、バリデーション、モデル未検出を分けます。業務例外と想定外エラーも分けます。そのほうが、ログや監視、フロントエンドの分岐が明確になります。

エラーメッセージはAPI側で日本語にすべきですか?

管理画面や社内システムでは、API側で表示メッセージを返す設計も現実的です。一方で、複数クライアントがある場合は判断が変わります。多言語対応やモバイルアプリがある場合も同じです。その場合、API側はcodeを返します。表示文言はクライアント側で管理するほうが扱いやすいことがあります。つまり、利用者と運用体制に合わせて決めるべきです。

まとめ:Laravel APIエラーハンドリングは責務分離で安定する

Laravel APIエラーハンドリングでは、例外をcatchしてJSONを返すだけでは不十分です。FormRequest、業務例外、例外ハンドラを分けます。HTTPステータスとログも分けて設計します。

まず、入力チェックはFormRequestに寄せます。次に、業務ルール違反はServiceやUseCaseで表現します。そして、例外ハンドラでAPI全体のJSONレスポンス形式をそろえます。

また、エラー設計では利用者向けのmessageを分けます。運用者向けのログと混ぜないことが重要です。trace_idやエラーコードを用意しておくと、問い合わせ対応がしやすくなります。障害調査もしやすくなります。

LaravelやREST APIの案件では、動く実装だけでは足りません。エラー時の仕様まで説明できるエンジニアが評価されやすくなります。API設計、例外処理、レビュー観点を案件選びでどう活かすか。そこを相談したい方は、現在の経験を整理するところから話せます。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア