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

入力の検証 — Form Request で境界を固める

ここから、データを書き込む側に入ります。

外部から届くデータは、何が入っているか分かりません。整数のはずのフィールドに文字列が来ることも、必須のはずのキーが無いことも、存在しない商品 ID が指定されることもあります。この章では、そうしたリクエストをどこで止めるかを決めます。

注文を作る API

作るのは POST /api/v1/orders です。リクエストの形はこうします。

リクエストの例
{
"customer_email": "taro.test@example.com",
"items": [
{ "product_id": 1, "quantity": 2 },
{ "product_id": 5, "quantity": 1 }
]
}

items が配列になっているところが、この章で扱う内容の中心です。1 つの注文に複数の商品が入り、それぞれに商品 ID と数量があります。

この章では会員機能を使いません

顧客の識別は customer_email で行います。第11章で会員登録とログインを導入し、そこで orders.user_id に切り替えます。いまはゲストが注文する形として読んでください。

ルートとコントローラを用意する

第4章では商品の読み取り 2 本しか公開しませんでした。注文用のルートを足します。

php artisan make:controller OrderController
routes/api.php
use App\Http\Controllers\OrderController;
use App\Http\Controllers\ProductController;

Route::apiResource('products', ProductController::class)->only(['index', 'show']);
Route::post('/orders', [OrderController::class, 'store']);

apiResource を使わず Route::post で 1 本だけ定義しているのは、この時点で必要なのが作成だけだからです。注文の一覧と詳細は認可が要るので、第12章で足します。

第4章で apiPrefix: 'api/v1' を設定していれば、実際の URL は POST /api/v1/orders になります。

まず、コントローラに直接書いてみる

app/Http/Controllers/OrderController.php
use Illuminate\Http\Request;

public function store(Request $request)
{
$validated = $request->validate([
'customer_email' => ['required', 'email'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);

// ここから注文を作る処理...
}

$request->validate() は、ルールを満たさなければその場で処理を止めます。API へのリクエストであれば 422 Unprocessable Entity と、どのフィールドがなぜ弾かれたかを示す JSON が返ります1

422 レスポンス
{
"message": "The customer email field is required. (and 1 more error)",
"errors": {
"customer_email": ["The customer email field is required."],
"items.0.quantity": ["The items.0.quantity field must be at least 1."]
}
}

items.0.quantity のように、配列の中のエラーは位置を含めた平坦なキーで返ります。クライアント側は、このキーを見てどの行のどの項目が問題かを特定できます。

Form Request に切り出す

コントローラに直接書いても動きますが、2 つの理由で外に出します。

1 つは、コントローラの本来の仕事が見えなくなることです。バリデーションのルールが 10 行あると、その下にある「注文を作る」処理が埋もれます。

もう 1 つは、同じルールを他の場所でも使いたくなることです。注文の更新 API を作れば、似たルールをもう一度書くことになります。

Laravel はこの切り出し先を用意しています。

php artisan make:request StoreOrderRequest
app/Http/Requests/StoreOrderRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
/**
* このリクエストを行う権限があるか
*/
public function authorize(): bool
{
return true;
}

/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'customer_email' => ['required', 'email', 'max:255'],
'items' => ['required', 'array', 'min:1', 'max:20'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
];
}
}

コントローラ側は、引数の型を Request から StoreOrderRequest に変えるだけです。

✅ コントローラが本来の仕事だけになる
public function store(StoreOrderRequest $request)
{
$validated = $request->validated();

// ここから注文を作る処理...
}

引数に型を書いた時点で、コントローラの中身が動く前に検証が終わっています1。ルールを満たさなければ 422 が返り、store メソッドは呼ばれません。コントローラの中では「検証を通ったデータが来ている」ことを前提にできます。

$request->validated() は、ルールに書いたフィールドだけを取り出します。リクエストに余計なキーが混ざっていても、ここには含まれません。第3章で見た mass assignment の防御と組み合わせると、二重に守れます。

authorize は「誰が」を判断する場所

authorize() は、このリクエストを行う権限があるかを返します。false を返すと 403 になります。

いまは認証がないので true を返しますが、第12章では次のような形になります。

第12章で書く形
public function authorize(): bool
{
return $this->user()->can('update', $this->order);
}

ここに業務ルールを書かないでください。「在庫が足りるか」「注文が確定済みでないか」といった判断は権限の話ではありません。authorize() が false を返すと 403 が返り、クライアントは「権限がない」と解釈します。在庫不足で 403 が返る API は、原因を取り違えさせます。

配列とネストしたデータを検証する

items のような配列には、専用の書き方があります。

ルール対象意味
'items' => ['required', 'array']配列そのもの存在し、配列であること
'items' => ['min:1', 'max:20']配列そのもの要素数が 1 以上 20 以下
'items.*.product_id' => [...]各要素の product_id* がすべての要素を指す

配列そのものと、その中身の両方にルールが要ります。itemsarray を書かないと、"items": "abc" のような文字列が来たときの扱いが不定になります。

ネストが深い場合は、. でつなげます。

'shipping.address.postal_code' => ['required', 'regex:/^\d{3}-\d{4}$/'],

要素数に上限を付ける

'items' => ['max:20'] を入れているのは、意図的です。

上限がないと、1 回のリクエストで 10 万件の明細を送れます。バリデーションだけで 10 万回のループが回り、その後の処理でも同じ件数を扱うことになります。上限を決めておくのは、入力の段階でリソースの消費量に天井を作ることです。

同じ理由で、items.*.quantity にも max:99 を入れています。1 つの商品を 100 万個注文できる EC サイトは、たいてい意図した仕様ではありません。

検証の前後に処理を挟む

prepareForValidation でデータを整える

検証にかける前にデータを加工したいことがあります。メールアドレスの前後に入った空白を落とす、といった処理です。

app/Http/Requests/StoreOrderRequest.php
protected function prepareForValidation(): void
{
if (is_string($this->input('customer_email'))) {
$this->merge([
'customer_email' => trim($this->input('customer_email')),
]);
}
}

merge() でリクエストの値を差し替えると、その後のルールは加工後の値を見ます1

is_string() の確認を入れているのが重要です。このメソッドはルールが動く前に実行されます。 つまり customer_email が文字列である保証はまだありません。{"customer_email": []} のようなリクエストが来ると、確認なしの trim() は TypeError を投げます。バリデーションエラーとして 422 を返すべき場面で、500 になってしまいます。

型を確認して、想定と違えば何もせずに通すのが正しい対応です。その値は後続の email ルールが 422 で弾きます。

ここでやるのは整形だけにしてください。値の意味を変える処理を書くと、クライアントが送ったデータと保存されたデータが食い違い、原因の追跡が難しくなります。

after で複数フィールドをまたぐ検証をする

1 つのフィールドだけでは判断できないルールもあります。「同じ商品を 2 行に分けて指定していないか」がその例です。

app/Http/Requests/StoreOrderRequest.php
use Illuminate\Validation\Validator;

/**
* @return array<int, callable>
*/
public function after(): array
{
return [
function (Validator $validator) {
$items = $this->input('items');

if (! is_array($items)) {
return;
}

$productIds = array_column($items, 'product_id');

if (count($productIds) !== count(array_unique($productIds))) {
$validator->errors()->add('items', '同じ商品を複数の行に分けて指定できません。');
}
},
];
}

after() が返したクロージャは、通常のルールがすべて済んだあとに呼ばれます1。エラーを足せば、他のエラーと同じ 422 のレスポンスに混ざります。

ここでも型の確認から始めています。items に配列でない値が来ていれば、array ルールが既にエラーを付けているはずです。それでもこのクロージャは実行されます。 確認せずに array_column() を呼ぶと、{"items": "abc"} のようなリクエストで TypeError になります。

after() の中では「前段のルールが通っている」と仮定しないでください。仮定できるのは、自分で確認した範囲だけです。

処理が長くなるなら、クラスに切り出して返せます。

public function after(): array
{
return [
new ValidateNoDuplicateProducts,
];
}

エラーメッセージを日本語にする

既定のメッセージは英語です。日本語にする方法が 2 つあります。

そのリクエスト専用のメッセージ

app/Http/Requests/StoreOrderRequest.php
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'items.required' => '注文する商品を 1 つ以上指定してください。',
'items.max' => '1 回の注文で指定できる商品は 20 種類までです。',
];
}

キーは フィールド名.ルール名 の形です1

フィールド名の表示だけ変える

メッセージ全体でなく、フィールド名の部分だけを日本語にしたい場合はこちらです。

/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'customer_email' => 'メールアドレス',
'items.*.quantity' => '数量',
];
}

メッセージの中の :attribute が置き換わります1。「The customer email field is required.」が「メールアドレスは必須です。」のような形になります。

アプリケーション全体で共通の言い回しにしたい場合は、言語ファイル側に書きます。リクエストごとに messages() を書き足していくと、同じ内容が散らばるためです。

どこまでを Form Request で検証するか

これがこの章で一番重要な判断です。

Form Request が扱うのは、リクエスト単体を見て判断できることに限ります。

判断場所
必須・型・書式customer_email がメールアドレスの形かForm Request
範囲quantity が 1 以上 99 以下かForm Request
存在product_idproducts に実在するかForm Request
リクエスト内の整合同じ商品が 2 行に分かれていないかForm Request (after)
在庫が足りるか商品 1 の在庫が 2 個以上あるかここでは判断しない
注文が確定済みでないかすでに発送された注文を変更しようとしていないかここでは判断しない

在庫チェックを Form Request に書かない理由

「在庫が足りるか」は書けそうに見えます。カスタムルールを作れば、items.*.product_iditems.*.quantity を突き合わせて在庫を確認できます。

書かないのは、確認した時点と、実際に在庫を減らす時点がずれるからです。

時刻 1 リクエスト A のバリデーション: 在庫 1 個 → OK
時刻 2 リクエスト B のバリデーション: 在庫 1 個 → OK
時刻 3 リクエスト A が在庫を減らす: 残り 0
時刻 4 リクエスト B が在庫を減らす: 残り -1

バリデーションで確認しても、その直後に別のリクエストが在庫を持っていく可能性があります。防ぐには、在庫を確認してから減らすまでを 1 つの不可分な処理にする必要があります。これは第14章のトランザクションと行ロックの領域です。

Form Request にも在庫チェックを書いておけば、多くの場合は早めに弾けます。ただし「早めに弾ける」だけで、正しさの保証にはなりません。保証は第14章の側にあり、そこが本当の防御線です。両方に書くと、防御線がどちらなのか読む人に伝わらなくなります。

層をどう分けるかの設計論

「どの層でどの検証をするか」をアーキテクチャの問題として整理したい場合は、Laravel × DDD × クリーンアーキテクチャ実践ガイドのプレゼンテーション層の設計が扱っています。この連載では層を分けず、Laravel の標準構成のままどこに書くかを決めます。

本番で効く注意点

422 のレスポンス形は変えられなくなる

エラーの本文は、クライアントが読む契約です。errors の中がフィールド名をキーにした配列である、という構造にクライアントのコードが依存します。

一度公開したあとにこの形を変えると、エラー表示が壊れます。Laravel の既定の形をそのまま使うのが安全です。 独自の形にするなら、第9章でエラー全体の設計を決めるときに一度で決めてください。

exists ルールはクエリを発行する

'items.*.product_id' => ['exists:products,id'] は、配列の要素ごとに 1 回ずつ SELECT を投げます。20 件の明細があれば 20 回です。

件数の上限を決めておけば影響は限定できますが、上限を大きくするときは意識してください。まとめて確認したい場合は after() の中で whereIn を 1 回投げる方法があります。

nullable と sometimes を取り違えない

似ていますが意味が違います。

  • nullableキーは必要だが、値が null でもよい
  • sometimesキーが無ければ検証しない。あれば他のルールを適用する

更新 API で「送られたフィールドだけ更新したい」場合は sometimes です。ここで nullable を使うと、キーを省略したときに「必須ではないが検証される」という中途半端な状態になります。

バリデーションを通ったデータだけを使う

❌ Bad: 検証していない値を使う
$order = Order::create([
'customer_email' => $request->input('customer_email'),
'total_amount' => $request->input('total_amount'), // ルールに無い
]);
✅ Good: validated() が返すものだけを使う
$validated = $request->validated();

$request->input() は検証の有無に関係なく値を返します。ルールに書いていないフィールドをここから取ると、検証を素通りした値がそのままデータベースに入ります。とくに total_amount のような金額は、クライアントに決めさせてはいけません。サーバー側で計算します。

まとめ

  • Form Request を型宣言すると、コントローラが動く前に検証が終わっている
  • $request->validated() はルールに書いたフィールドだけを返す。余計なキーは落ちる
  • 配列は「配列そのもの」と「各要素」の両方にルールが要る。要素数の上限も決める
  • authorize() は権限の判断だけ。業務ルールを書くと 403 の意味が壊れる
  • 在庫の確認は Form Request でやらない。確認と更新のあいだに他のリクエストが入るので、正しさを保証できない。第14章で扱う
  • 422 のレスポンス形はクライアントとの契約。既定の形を変えない

次に読む

次章 ビジネスロジックの置き場所 では、この store メソッドの中身を書きます。素朴に書くと在庫の確認・注文の作成・明細の作成・メールの送信がすべてコントローラに並びます。それを診断して、サービスクラスへ切り出します。Laravel のサービスコンテナが依存を組み立てる仕組みも、そこで扱います。

練習問題

次の Form Request には、レスポンスの意味を壊す問題があります。指摘してください
class UpdateOrderRequest extends FormRequest
{
public function authorize(): bool
{
$order = Order::find($this->route('order'));

return $order && $order->status === OrderStatus::Pending;
}

public function rules(): array
{
return [
'customer_email' => ['sometimes', 'email'],
];
}
}

解答例

authorize()業務ルールが書かれています。「注文が Pending の状態かどうか」は権限の話ではありません。

authorize()false を返すと 403 Forbidden が返ります。クライアントから見ると「あなたにはこの操作をする権限がありません」という意味です。しかし実際に起きているのは「この注文はすでに発送済みなので変更できません」であり、権限とは無関係です。

利用者が問い合わせてきたときに、サポート側は「権限の問題です」と回答してしまいます。原因は権限設定ではないので、調査が空振りします。

状態の矛盾を表すコードは 409 Conflict です。この判断はコントローラかサービスクラスで行い、専用の例外を投げます。第9章でその形を作ります。

なお authorize()Order::find() を書いている点にも無駄があります。ルートモデルバインディングを使えば $this->order で取得できるので、同じ行を 2 回引くことになりません。

注文作成 API に total_amount を受け取るルールを足したい、という要望が来ました。どう応じますか

解答例

受け取りません。 クライアントが計算した金額をそのまま保存すると、いくらでも書き換えられます。

{
"customer_email": "taro.test@example.com",
"items": [{ "product_id": 1, "quantity": 100 }],
"total_amount": 1
}

10 万円の商品を 100 個注文して、合計 1 円で保存できてしまいます。バリデーションで integermin:0 を確認しても、値が正しいかどうかは判断できません。正しい金額を知っているのはサーバー側だけです。

金額はサーバーで計算します。product_id から現在の価格を引き、quantity を掛けて合計します。第3章で order_items.unit_price を用意したのは、この計算結果を注文時点の値として固定するためです。

要望の背景に「クライアント側でも合計を表示したい」があるなら、答えは別のエンドポイントです。金額を計算して返すだけの API を用意すれば、表示には使えて保存には影響しません。

同じ判断は、割引の適用やポイントの使用にも当てはまります。クライアントから来た値を検証するのではなく、そもそも受け取らないのが基本です。


Footnotes

  1. 出典: Validation(Laravel 公式ドキュメント 13.x)。Form Request の型宣言によるコントローラ実行前の検証、XHR リクエストに対する 422 レスポンス、配列とネストしたデータの検証、prepareForValidation / after / messages / attributes の各メソッドについて。 2 3 4 5 6