エラー設計 — 例外を HTTP レスポンスに翻訳する
ここまで、エラーは場当たり的に返してきました。
return response()->json(['message' => '在庫が足りません'], 409);
これでも動きますが、エラーを返す場所が増えるたびに形が少しずつずれます。ある場所は message、別の場所は error、さらに別の場所は errors の配列。クライアントは 3 通りの読み方を実装することになります。
この章では形を 1 つに決め、変換する場所も 1 箇所にまとめます。
エラーレスポンスの形を決める
決めるべきことは 3 つです。
HTTP ステータスコードは第4章で決めました。クライアントはまずこれで大分類を判断します。
本文の形をここで決めます。
{
"message": "商品 3 の在庫が足りません。",
"code": "INSUFFICIENT_STOCK",
"details": {
"product_id": 3,
"requested": 5,
"available": 2
}
}
3 つのキーには役割があります。
| キー | 読み手 | 用途 |
|---|---|---|
message | 人間 | そのまま画面に出せる文言 |
code | プログラム | 分岐に使う識別子。文言が変わっても不変 |
details | プログラム | 復旧に必要な追加情報 |
code を分けるのが要点です。message で分岐すると、文言を「商品 3 の在庫が足りません。」から「申し訳ありません。ただいま在庫を切らしております。」に変えた瞬間にクライアントが壊れます。表示のための文字列と、判断のための識別子は別物です。
details があると、クライアントは気の利いた動きができます。在庫が 2 個しかないと分かれば、「2 個なら購入できます」という選択肢を出せます。
バリデーションエラーは既定のまま
第5章で見たとおり、バリデーション失敗のレスポンスは Laravel が用意した形です。
{
"message": "The customer email field is required. (and 1 more error)",
"errors": {
"customer_email": ["The customer email field is required."]
}
}
これを独自の形に揃えたくなりますが、変えないことを勧めます。Laravel の標準形はクライアント側のライブラリが対応していることが多く、揃えた結果それらが使えなくなります。
両方に message があるので、クライアントは「まず message を読む」という共通の処理を書けます。422 のときだけ errors も見る、という分岐で済みます。
Laravel が既定で返すエラー
自分で書く前に、何が自動で返っているかを把握します。
| 状況 | コード | 誰が返すか |
|---|---|---|
| ルートモデルバインディングで見つからない | 404 | Laravel |
| 定義していない URL | 404 | Laravel |
| メソッドが違う (GET のルートに POST) | 405 | Laravel |
| バリデーション失敗 | 422 | Laravel |
| レート制限 (第13章) | 429 | Laravel |
| 未捕捉の例外 | 500 | Laravel |
第2章で見た bootstrap/app.php の設定が効いています。
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})
api/* へのリクエストであれば、HTML のエラーページでなく JSON が返ります1。この設定があるおかげで、何も書かなくても 404 は JSON で返っています。
カスタム例外クラスを作る
第6章で StockChecker が投げると予告した例外を作ります。
php artisan make:exception InsufficientStockException
<?php
namespace App\Exceptions;
use Exception;
class InsufficientStockException extends Exception
{
public function __construct(
public readonly int $productId,
public readonly int $requested,
public readonly int $available,
) {
parent::__construct("商品 {$productId} の在庫が足りません。");
}
}
コンストラクタで復旧に必要な情報を受け取ります。単に「在庫が足りません」と投げるより、どの商品がいくつ足りないかを持たせたほうが、あとで使えます。
引数に付いている public readonly int は PHP 8 の書き方で、2 つのことを同時にしています。コンストラクタでプロパティを宣言することと、そのプロパティを読み取り専用にすることです。$this->productId = $productId; を書く必要がなく、あとから値を書き換えることもできません。例外が持つ情報は投げた時点で確定しているべきなので、この形が合っています。
投げる側はこうなります。
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->get()
->keyBy('product_id');
foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;
if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}
productId: のように引数名を書いているのは PHP 8 の名前付き引数です。new InsufficientStockException(3, 5, 2) と書いても動きますが、数字が 3 つ並ぶと何がどれか分かりません。名前を書けば、引数の順序を間違えたときにも気づけます。
なぜ例外にするのか
return false や return ['error' => '...'] で表す方法もあります。例外を選ぶ理由は 2 つです。
呼び出し側が無視できません。 戻り値のチェックを忘れると、在庫が足りないまま処理が続きます。例外なら、捕まえないかぎり処理は止まります。
途中の階層を素通りできます。 StockChecker → OrderPlacementService → コントローラと 3 階層あるとき、戻り値で伝えるには各階層が受け取って返し直す必要があります。例外は一番上まで飛びます。
withExceptions で翻訳する
例外は投げられました。これを HTTP レスポンスに変換します。
変換を書ける場所は 2 つあります。例外クラス自身と、bootstrap/app.php です。
例外クラスに書く方法
例外クラスに render メソッドを定義すると、Laravel が自動的に呼びます1。
public function render(Request $request): Response
{
return response()->json([
'message' => $this->getMessage(),
'code' => 'INSUFFICIENT_STOCK',
'details' => [
'product_id' => $this->productId,
'requested' => $this->requested,
'available' => $this->available,
],
], 409);
}
例外とレスポンスが同じファイルにあるので、対応が分かりやすくなります。
bootstrap/app.php に書く方法
もう 1 つは、変換を 1 箇所に集める方法です。
use App\Exceptions\InsufficientStockException;
use Illuminate\Http\Request;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->render(function (InsufficientStockException $e, Request $request) {
return response()->json([
'message' => $e->getMessage(),
'code' => 'INSUFFICIENT_STOCK',
'details' => [
'product_id' => $e->productId,
'requested' => $e->requested,
'available' => $e->available,
],
], 409);
});
})
クロージャの引数の型で対象の例外が決まります1。InsufficientStockException を型宣言すれば、その例外だけがここに来ます。
どちらを選ぶか
この連載では bootstrap/app.php に集めます。
理由は、レスポンスの形を揃えやすいことです。例外クラスごとに render を書くと、10 個の例外クラスに 10 個の JSON 構築コードが散らばります。1 箇所にまとまっていれば、キーの構成がずれていないかを 1 画面で確認できます。
例外クラス側に書くのが向くのは、その例外に固有の複雑な変換がある場合です。外部サービスのエラーコードを自前のコードに読み替える、といった処理を持つときは、例外クラスに置いたほうが bootstrap/app.php が膨らみません。
組み込み例外の形も変えられる
Laravel が返す 404 のメッセージは英語です。API 全体で日本語にしたいなら、これも変換できます1。
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => '指定されたリソースが見つかりません。',
'code' => 'NOT_FOUND',
], 404);
}
});
何をログに送り、何を送らないか
例外が起きたとき、レスポンスとは別にログが残ります。ここも制御できます。
$exceptions->report(function (InsufficientStockException $e) {
return false;
});
false を返すと、この例外は既定のログスタックに流れません1。->stop() を使っても同じです。
在庫不足をログに残さないのは、それが想定内の出来事だからです。利用者が在庫より多く注文しようとするのは正常な操作です。これをエラーログに残すと、本当に調べるべき例外がその中に埋もれます。
区別の目安を挙げます。
| 種類 | 例 | ログ |
|---|---|---|
| 想定内 | 在庫不足・重複注文・権限なし | 残さない (または info レベル) |
| 想定外 | データベース接続失敗・null 参照・型エラー | 残す (error レベル) |
「エラーレスポンスを返した = 異常」ではありません。409 も 422 も、正しく動いた結果です。
本章で扱わないこと
- 例外の階層設計 — 業務例外の基底クラスを作り、種類ごとに継承ツリーを組む設計。例外が数十種類になったときに効きます。エラーハンドリング が扱います
- Result 型パターン — 例外を投げず、成功と失敗を型で表す方式。同上
- ログの送信先 — ファイル・Sentry・CloudWatch などへの振り分けは第21章で扱います
本番で効く注意点
APP_DEBUG=true のまま本番に出さない
第2章でも触れましたが、エラーの文脈で改めて書きます。
APP_DEBUG=true で未捕捉の例外が起きると、レスポンスにスタックトレースが含まれます。ファイルの絶対パス、実行中のコード、そして引数として渡っていた値が出ます。データベースのパスワードが接続処理の引数に含まれていれば、それも見えます。
本番では必ず false にしてください。false なら、500 のレスポンスは「Server Error」だけを返します。
500 の本文に原因を書かない
想定外の例外が起きたとき、原因をクライアントに伝えたくなります。伝えないでください。
$exceptions->render(function (Throwable $e, Request $request) {
return response()->json(['message' => $e->getMessage()], 500);
});
例外のメッセージには内部の情報が入ります。SQLSTATE[42S02]: Base table or view not found: 1146 Table 'shop.orders_backup' doesn't exist のようなメッセージは、テーブル名とデータベース名を教えます。
代わりに、追跡用の ID を返します。
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
$exceptions->render(function (Throwable $e, Request $request) {
// Laravel が扱いを決めている例外はそのまま任せる
if ($e instanceof HttpExceptionInterface // 404 / 405 など
|| $e instanceof ValidationException // 422
|| $e instanceof AuthenticationException // 401
|| $e instanceof AuthorizationException) { // 403
return;
}
$errorId = (string) Str::uuid();
Log::error($e->getMessage(), ['error_id' => $errorId, 'exception' => $e]);
return response()->json([
'message' => 'サーバー側で問題が発生しました。',
'code' => 'INTERNAL_ERROR',
'error_id' => $errorId,
], 500);
});
利用者が問い合わせてきたとき、error_id でログを検索すれば該当の例外にたどり着けます。内部情報を出さずに、調査可能性だけを残せます。 第21章でログの設計を扱うときに、この形を発展させます。
Throwable を型宣言したクロージャはすべての例外に一致します。バリデーション失敗の ValidationException も、404 を表す NotFoundHttpException も、ここに来ます。
上のコードで return; している箇所を消すと、422 で返るはずのバリデーションエラーが 500 になります。公式ドキュメントは「クロージャが値を返さなければ Laravel の既定の変換が使われる」と定めているので、任せたい例外では何も返さずに抜けます1。
除外リストをインターフェース 1 つで済ませられません。 HttpExceptionInterface だけを書くと、認証エラー (401) と認可エラー (403) が漏れます。AuthenticationException と AuthorizationException は Exception を直接継承しており、このインターフェースを実装していないためです。
第11章で認証を、第12章で認可を入れます。除外を書き忘れていると、そこで 401 や 403 を返すはずの経路がすべて 500 になります。しかもレスポンスは「サーバー側で問題が発生しました」です。原因が認証の設定なのか本当の障害なのか、受け取った側には区別できません。
型を絞れる場合は、Throwable でなく個別の例外型で受けるほうが安全です。この節のように「想定外の例外すべて」を対象にしたいときだけ Throwable を使い、除外を明示したうえで、認証・認可を足すたびに漏れがないかを確認してください。
例外を握り潰さない
try {
$this->paymentGateway->charge($order);
} catch (Throwable $e) {
// とりあえず続行
}
このコードは、決済が失敗しても注文を成功として扱います。障害が起きても誰も気づかず、後日「入金がない注文がある」として発覚します。
捕まえるなら、捕まえた理由を明示できる形にします。
try {
$this->analytics->track('order_placed', $order->id);
} catch (Throwable $e) {
// 分析基盤への送信失敗は注文の成否に影響させない
Log::warning('分析イベントの送信に失敗しました', ['exception' => $e]);
}
「この処理が失敗しても本体は続けてよい」という判断があり、ログにも残しています。決済との違いは、失敗したときに困る人がいるかどうかです。
エラーの形は公開後に変えられない
message / code / details という構成は、クライアントが依存する契約です。第4章と第7章で見たのと同じ非対称性があります。キーの追加は壊さず、削除と名前の変更は壊します。
code の値も同じです。INSUFFICIENT_STOCK を STOCK_SHORTAGE に変えると、その値で分岐しているクライアントが壊れます。値は最初に決めて、変えないでください。
まとめ
- エラー本文は
message(人間用) /code(プログラム用) /details(復旧用) に分ける messageで分岐させない。文言を変えた瞬間にクライアントが壊れる- バリデーションの 422 は Laravel 既定の形のまま使う。独自の形に揃えない
- カスタム例外は復旧に必要な情報をコンストラクタで受け取る
- 変換は
bootstrap/app.phpのwithExceptionsに集める。形のずれを 1 画面で確認できる - 想定内のエラー (在庫不足・権限なし) はログに残さない。本当に調べるべき例外が埋もれる
- 500 の本文に原因を書かず、追跡用の ID を返す
次に読む
次章 テスト — Pest で API の契約を固定する では、ここまでに作った API にテストを書きます。Pest の書き方を導入し、正常系と失敗系の両方を固定します。第10章以降の各章は末尾に「テスト」の節を持つので、そこがこの連載のテストの起点になります。
練習問題
次のエラー設計には、クライアントが安全に分岐できない問題があります。指摘してください
$exceptions->render(function (InsufficientStockException $e) {
return response()->json([
'message' => "商品 {$e->productId} の在庫が足りません。残り {$e->available} 個です。",
], 409);
});
$exceptions->render(function (OrderAlreadyShippedException $e) {
return response()->json([
'message' => 'この注文はすでに発送済みのため変更できません。',
], 409);
});
解答例
どちらも 409 を返し、message しか持ちません。クライアントはこの 2 つを区別できません。
在庫不足なら「数量を減らして再試行」の導線を出したいところですが、発送済みエラーで同じ導線を出すのは的外れです。区別するには message の文字列を判定するしかありません。
// クライアント側でこうなる
if (error.message.includes('在庫が足りません')) {
showQuantityAdjustment();
}
これは 2 つの理由で壊れます。文言を変えたときに条件が外れます。「在庫が足りません」を「ただいま品切れです」に変えれば、分岐は動かなくなります。多言語対応をしたときも同様で、英語のメッセージには「在庫」という文字列がありません。
code を足せば解決します。
'code' => 'INSUFFICIENT_STOCK',
'code' => 'ORDER_ALREADY_SHIPPED',
さらに、在庫不足には details で残数を渡します。message から数字を正規表現で抜き出す必要がなくなります。
「エラーが起きたら全部ログに残すべきだ」という方針にどう応じますか
解答例
部分的に賛成しますが、そのままでは運用できません。
方針の背景にある「見落としを防ぎたい」は正しい懸念です。問題は、全部残すと本当に調べるべきものが埋もれることです。
在庫不足を error レベルで残すとどうなるか考えます。人気商品が品切れした日、そのエラーは 1 日に数千件出ます。同じ日にデータベースの接続エラーが 3 件起きていても、数千件のログの中から見つけるのは困難です。監視ツールでエラー数の閾値を設定していれば、在庫不足だけで閾値を超えて通知が飛び続け、やがて誰も通知を見なくなります。
現実的な落としどころは、レベルを分けることです。
error— 想定外。対応が要る。データベース接続失敗、null 参照、外部 API のタイムアウトwarning— 想定内だが頻度を見たい。決済の一時的な失敗、リトライで回復した処理info— 記録として残す。在庫不足、権限エラー
「残さない」ではなく「残す場所を分ける」という形なら、見落としも防げて調査もできます。在庫不足の発生数を知りたいという要望自体は正当なので、info に残しておけばあとから集計できます。
第21章でログの設計を扱うときに、この振り分けを具体化します。