メインコンテンツまでスキップ

例外設計とバリデーション — Form Requestとカスタム例外

第10章のOrderControllerでは、リクエストのバリデーションを$request->validate([...])でその場に書き、在庫不足はRuntimeExceptionで表現していました。 この章では、この2つをもう少し丁寧に設計します。 バリデーションのルールをForm Requestクラスにまとめる方法と、意図を明確にするカスタム例外クラスの作り方を見ていきます。

Form Requestでバリデーションをまとめる

第10章のControllerは、次のようにバリデーションをメソッドの中に直接書いていました。

第10章のControllerの再掲(抜粋)
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);
// ...
}

このルールをForm Requestという専用のクラスに切り出します。

✅ Good: Form Requestクラス
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'customer_email' => ['required', 'email'],
];
}
}

Controllerでは、Requestの代わりにこのクラスを型宣言するだけで済みます。

✅ Good: Form Requestを使うController
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}

public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);

return response()->json($order, 201);
}

StoreOrderRequestを型宣言すると、Controllerのメソッドが実行される前にLaravelが自動でバリデーションを行います。 チェックに失敗すると、422ステータスとエラー内容を含むレスポンスが自動的に返されます1rules()が返すのは、必須・型・範囲・存在といった、リクエストの形式についてのチェックです。

カスタム例外クラスで意図を明確にする

第10章のOrderPlacementServiceは、在庫が不足しているときにRuntimeExceptionを投げていました。

第10章のOrderPlacementServiceの再掲(抜粋)
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new RuntimeException('在庫が不足しています');
}

RuntimeExceptionは汎用的な例外クラスであり、この例外を受け取った側は、メッセージ文字列を見るまで何が起きたのか分かりません。 専用の例外クラスを定義すると、型そのものが意図を表すようになります。

✅ Good: カスタム例外クラス
class InsufficientStockException extends RuntimeException
{
}
✅ Good: カスタム例外を投げる
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new InsufficientStockException('在庫が不足しています');
}

呼び出し側はcatch (InsufficientStockException $e)のように、型で例外の種類を区別できます。 在庫不足だと分かった時点ですぐに例外を投げ、それ以降の注文作成やメール送信をしないという設計を、fail-fastと呼びます2。 条件を満たさないと分かった瞬間に処理を打ち切ることで、無効な状態のまま処理が先に進むことを防ぎます。

例外をHTTPレスポンスに変換する

第10章のControllerは、try-catchRuntimeExceptionを捕まえてHTTPレスポンスに変換していました。

第10章のControllerの再掲(抜粋)
try {
$order = $this->orderPlacementService->placeOrder(/* ... */);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}

同じ変換を全てのControllerで書くと、同じコードが繰り返されます。 Laravel 11以降では、bootstrap/app.phpwithExceptions()に例外の変換方法を1か所にまとめて登録できます3

✅ Good: 例外の変換を1か所にまとめる
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (InsufficientStockException $e) {
return response()->json(['error' => $e->getMessage()], 422);
});
})->create();

在庫不足はビジネスルール違反であり、リクエストの形式チェック(Form Request)とは性質が異なりますが、ここでは422に統一しています。この使い分けについては、後述の「本章で扱わないこと」で補足します。

こう登録しておけば、Controller側はtry-catchを書かずに済みます。

✅ Good: try-catchが不要になったController
public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);

return response()->json($order, 201);
}

本章で扱わないこと ── ビジネスルールバリデーションの置き場所

items.*.quantitymin:1のような制約はForm Requestで判断できますが、在庫確認のようにデータベースの現在の状態を見て判断する制約は事情が異なります。 第10章で在庫確認をForm RequestでなくOrderPlacementServiceの中で行ったのは、この違いによるものです。

在庫確認のような、ドメイン知識を要するビジネスルールの検証をどこに置くべきかについての詳しい設計指針は、本ガイドの範囲を超えます。 Laravel × DDD × クリーンアーキテクチャ実践入門プレゼンテーション層の設計が、Form Requestとドメイン層の役割分担を詳しく扱っているので、本格的に設計したい場合はそちらを参照してください。

なお、本章では形式チェック・ビジネスルール違反のどちらも422で統一していますが、両者を異なるHTTPステータスコード(例: ビジネスルール違反には400)で使い分ける設計もあります。この使い分けも上記の参照先で扱っています。

まとめ

  • バリデーションのルールはForm Requestクラスにまとめる。Controllerの型宣言を変えるだけで、実行前の自動チェックが行われる
  • 汎用的な例外の代わりにカスタム例外クラスを定義すると、型そのものが意図を表す
  • 条件を満たさないと分かった時点ですぐに例外を投げる設計をfail-fastと呼ぶ
  • 例外からHTTPレスポンスへの変換は、bootstrap/app.phpwithExceptions()に1か所にまとめられる
  • ドメイン知識を要するビジネスルールの検証は本ガイドの範囲外

次に読む

次章では、ここまでの原則を総動員してFat Controller・Fat Modelをリファクタリングします

練習問題

次の2つのチェックは、Form Requestとカスタム例外のどちらで扱うべきでしょうか。理由とともに答えてください
  1. 注文明細(items)が1件も無い場合はエラーにしたい
  2. 注文の合計金額が、顧客の当日の購入上限(データベースに保存された値)を超えている場合はエラーにしたい

解答例

  1. Form Request: itemsが配列として1件以上あるかという形式的なチェックなので、'items' => ['required', 'array', 'min:1']で表現できます
  2. カスタム例外: 顧客ごとの購入上限という、データベースの現在の状態を見なければ判断できないビジネスルールです。サービスクラスの中でチェックし、専用の例外(例: PurchaseLimitExceededException)を投げるのが適切です

Footnotes

  1. 出典: Laravel公式ドキュメント — Validation: Creating Form Requests。XHRリクエストの場合、バリデーションエラーの内容を含む422ステータスのレスポンスが自動的に返されると説明されています。

  2. 出典: Fail-fast。異常を検知した時点で即座に処理を停止する設計原則です。

  3. 出典: Laravel 11 リリースノート。例外処理の設定は、Laravel 10以前のapp/Exceptions/Handler.phpから、Laravel 11ではbootstrap/app.phpwithExceptions()に変更されました。