例外設計とバリデーション — Form Requestとカスタム例外
第10章のOrderControllerでは、リクエストのバリデーションを$request->validate([...])でその場に書き、在庫不足はRuntimeExceptionで表現していました。
この章では、この2つをもう少し丁寧に設計します。
バリデーションのルールをForm Requestクラスにまとめる方法と、意図を明確にするカスタム例外クラスの作り方を見ていきます。
Form Requestでバリデーションをまとめる
第10章のControllerは、次のようにバリデーションをメソッドの中に直接書いていました。
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);
// ...
}
このルールを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の代わりにこのクラスを型宣言するだけで済みます。
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ステータスとエラー内容を含むレスポンスが自動的に返されます1。
rules()が返すのは、必須・型・範囲・存在といった、リクエストの形式についてのチェックです。
カスタム例外クラスで意図を明確にする
第10章のOrderPlacementServiceは、在庫が不足しているときにRuntimeExceptionを投げていました。
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new RuntimeException('在庫が不足しています');
}
RuntimeExceptionは汎用的な例外クラスであり、この例外を受け取った側は、メッセージ文字列を見るまで何が起きたのか分かりません。
専用の例外クラスを定義すると、型そのものが意図を表すようになります。
class InsufficientStockException extends RuntimeException
{
}
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new InsufficientStockException('在庫が不足しています');
}
呼び出し側はcatch (InsufficientStockException $e)のように、型で例外の種類を区別できます。
在庫不足だと分かった時点ですぐに例外を投げ、それ以降の注文作成やメール送信をしないという設計を、fail-fastと呼びます2。
条件を満たさないと分かった瞬間に処理を打ち切ることで、無効な状態のまま処理が先に進むことを防ぎます。
例外をHTTPレスポンスに変換する
第10章のControllerは、try-catchでRuntimeExceptionを捕まえてHTTPレスポンスに変換していました。
try {
$order = $this->orderPlacementService->placeOrder(/* ... */);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
同じ変換を全てのControllerで書くと、同じコードが繰り返されます。
Laravel 11以降では、bootstrap/app.phpのwithExceptions()に例外の変換方法を1か所にまとめて登録できます3。
// 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を書かずに済みます。
public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);
return response()->json($order, 201);
}
本章で扱わないこと ── ビジネスルールバリデーションの置き場所
items.*.quantityのmin:1のような制約はForm Requestで判断できますが、在庫確認のようにデータベースの現在の状態を見て判断する制約は事情が異なります。
第10章で在庫確認をForm RequestでなくOrderPlacementServiceの中で行ったのは、この違いによるものです。
在庫確認のような、ドメイン知識を要するビジネスルールの検証をどこに置くべきかについての詳しい設計指針は、本ガイドの範囲を超えます。 Laravel × DDD × クリーンアーキテクチャ実践入門のプレゼンテーション層の設計が、Form Requestとドメイン層の役割分担を詳しく扱っているので、本格的に設計したい場合はそちらを参照してください。
なお、本章では形式チェック・ビジネスルール違反のどちらも422で統一していますが、両者を異なるHTTPステータスコード(例: ビジネスルール違反には400)で使い分ける設計もあります。この使い分けも上記の参照先で扱っています。
まとめ
- バリデーションのルールはForm Requestクラスにまとめる。Controllerの型宣言を変えるだけで、実行前の自動チェックが行われる
- 汎用的な例外の代わりにカスタム例外クラスを定義すると、型そのものが意図を表す
- 条件を満たさないと分かった時点ですぐに例外を投げる設計をfail-fastと呼ぶ
- 例外からHTTPレスポンスへの変換は、
bootstrap/app.phpのwithExceptions()に1か所にまとめられる - ドメイン知識を要するビジネスルールの検証は本ガイドの範囲外
次に読む
次章では、ここまでの原則を総動員してFat Controller・Fat Modelをリファクタリングします。
練習問題
次の2つのチェックは、Form Requestとカスタム例外のどちらで扱うべきでしょうか。理由とともに答えてください
- 注文明細(
items)が1件も無い場合はエラーにしたい - 注文の合計金額が、顧客の当日の購入上限(データベースに保存された値)を超えている場合はエラーにしたい
解答例
- Form Request:
itemsが配列として1件以上あるかという形式的なチェックなので、'items' => ['required', 'array', 'min:1']で表現できます - カスタム例外: 顧客ごとの購入上限という、データベースの現在の状態を見なければ判断できないビジネスルールです。サービスクラスの中でチェックし、専用の例外(例:
PurchaseLimitExceededException)を投げるのが適切です