Laravel フォームバリデーション
バリデーションはLaravelの「セキュリティチェックポイント」です。システムに入るすべてのデータはバリデーションを通過し, 1つでも不正なデータが漏れることはありません。
1. 学ぶ内容
- バリデーションルール:required/email/unique/exists/file/image/custom
- フォームリクエストバリデーションクラス:make:request
- カスタムバリデーションルール:クロージャルールとルールオブジェクト
- エラーメッセージ処理:$errorsビュー共有とAPI JSONレスポンス
- 条件バリデーションと「bail/sometimes」ルールのバリデーション
2. セキュリティ監査員からの実話
(1) ペインポイント:ユーザー入力によるデータ不整合
AliceがShopMetricsに登録した際, メールアドレスに"abc", 電話番号に"123"と入力し, 店舗名は空白のままでした。システムはすべてを受け入れてしまい, データベースには無効なデータが溢れました。悪意のあるユーザーがAPI経由でBobの店舗に負の価格の商品を送信し, 注文金額が-999ドルとなり, 財務レポートがクラッシュしました。セキュリティ監査で, Charlieは17のエンドポイントに一切入力バリデーションがないことを発見しました。
(2) Laravelバリデーションのソリューション
Laravelバリデーションはデータがシステムに入る前にインターセプトします。1つのルール配列で全バリデーションロジックをカバーし, FormRequestクラスがコントローラをすっきり保ちます。
// バリデーションルール — 30秒で書けて, 永遠に守る
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'price' => 'required|numeric|min:0',
]);
(3) 成果
Aliceがバリデーションを有効にした後, 無効な登録はゼロになり, Bobの商品価格が二度と負になることはなく, Charlieは17のエンドポイントすべてでセキュリティ監査を通過しました。
3. バリデーションルールクイックリファレンス
(1) よく使うルール
| ルール | 説明 | 例 |
|---|---|---|
required |
必須 | 'name' => 'required' |
email |
メール形式 | 'email' => 'required|email' |
unique:table,column |
一意 | 'slug' => 'unique:shops,slug' |
exists:table,column |
存在 | 'shop_id' => 'exists:shops,id' |
min:n |
最小値/長さ | 'price' => 'numeric|min:0' |
max:n |
最大値/長さ | 'name' => 'string|max:255' |
numeric |
数値 | 'quantity' => 'required|numeric' |
integer |
整数 | 'page' => 'integer|min:1' |
string |
文字列 | 'name' => 'required|string' |
boolean |
ブーリアン | 'is_active' => 'boolean' |
date |
日付 | 'published_at' => 'date' |
file |
ファイル | 'logo' => 'file|max:2048' |
image |
画像ファイル | 'photo' => 'image|mimes:jpeg,png' |
confirmed |
再確認 | 'password' => 'confirmed' |
regex:pattern |
正規表現 | 'phone' => 'regex:/^[0-9]{10}$/' |
in:a,b,c |
列挙値 | 'status' => 'in:active,suspended' |
(2) バリデーションパイプライン処理
flowchart TD
A[リクエスト入力] --> B[バリデーションルール]
B -->|通過| C[サニタイズ済みデータ]
C --> D[コントローラロジック]
B -->|失敗| E[エラー付きでリダイレクト戻り]
E --> F["ビューで\$errorsを表示"]
(1) ▶ サンプル:ShopMetrics商品作成バリデーション
// コントローラ内のインラインバリデーション
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'sku' => 'required|string|unique:products,sku',
'price' => 'required|numeric|min:0.01|max:999999.99',
'stock' => 'required|integer|min:0',
'category_id' => 'required|exists:categories,id',
'description' => 'nullable|string|max:5000',
'is_active' => 'boolean',
]);
$product = Product::create($validated);
return redirect()->route('products.show', $product)
->with('success', 'Product created.');
}
出力:
// 実行成功
4. フォームリクエストバリデーションクラス
(1) フォームリクエストの作成
php artisan make:request StoreShopRequest
php artisan make:request UpdateShopRequest
(2) ルールと認可の定義
// app/Http/Requests/StoreShopRequest.php
class StoreShopRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check() && auth()->user()->can('create', Shop::class);
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|string|unique:shops,slug',
'description' => 'nullable|string|max:5000',
'domain' => 'nullable|url',
'status' => 'in:active,suspended',
];
}
public function messages(): array
{
return [
'name.required' => 'Shop name is required.',
'slug.unique' => 'This URL slug is already taken.',
'domain.url' => 'Please enter a valid URL.',
];
}
}
(3) コントローラでの使用
public function store(StoreShopRequest $request): RedirectResponse
{
// $request->validated()はバリデーション済みデータのみ含む
$shop = Shop::create($request->validated());
return redirect()->route('shops.show', $shop);
}
| 項目 | インラインバリデーション | フォームリクエスト |
|---|---|---|
| 場所 | コントローラメソッド内 | 別クラスファイル |
| 再利用性 | 低い | ✅ 複数コントローラで再利用可能 |
| 認可チェック | 手動 | ✅ authorize() |
| コントローラコード | 長くなる | スリム |
| 適用場面 | 単純なバリデーション | 複雑/再利用可能なバリデーション |
(1) ▶ サンプル:ShopMetrics StoreShopRequest
// app/Http/Requests/StoreShopRequest.php
class StoreShopRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->user()->role === 'tenant_owner';
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|alpha_dash|unique:shops,slug,NULL,id,tenant_id,' . tenant()->id,
'description' => 'nullable|string|max:5000',
'status' => 'sometimes|in:active,suspended',
];
}
public function messages(): array
{
return [
'slug.unique' => 'You already have a shop with this slug.',
'slug.alpha_dash' => 'Slug can only contain letters, numbers, dashes.',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'tenant_id' => tenant()->id,
'slug' => Str::slug($this->slug ?? $this->name),
]);
}
}
出力:
// 実行成功
5. カスタムバリデーションルール
(1) クロージャルール
// インラインカスタムルール
$validated = $request->validate([
'discount' => [
'required',
'numeric',
'min:0',
function (string $attribute, mixed $value, Closure $fail) {
if ($value > request('subtotal')) {
$fail('The discount cannot exceed the subtotal.');
}
},
],
]);
(2) ルールオブジェクト
php artisan make:rule ValidCouponCode
// app/Rules/ValidCouponCode.php
class ValidCouponCode implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$coupon = Coupon::where('code', $value)
->where('expires_at', '>', now())
->where('usage_limit', '>', DB::raw('usage_count'))
->first();
if (!$coupon) {
$fail('This coupon code is invalid or expired.');
}
}
}
// 使用例
public function rules(): array
{
return [
'coupon_code' => ['nullable', 'string', new ValidCouponCode()],
];
}
| 方法 | 適用場面 | 再利用性 |
|---|---|---|
| クロージャ | 単純な1回きりのロジック | 低い |
| ルールオブジェクト | 複雑/再利用可能なロジック | ✅ |
Rule::classメソッド |
データベース関連のバリデーション | ✅ |
(3) Ruleクラスメソッド
use Illuminate\Validation\Rule;
// 現在のモデルを無視するユニーク
'slug' => Rule::unique('shops', 'slug')->ignore($shop->id),
// 動的値でのin
'status' => Rule::in(['active', 'suspended', 'closed']),
// 追加クエリ付きのexists
'shop_id' => Rule::exists('shops', 'id')->where(function ($query) {
$query->where('tenant_id', tenant()->id);
}),
(1) ▶ サンプル:ShopMetrics注文バリデーションルール
// app/Http/Requests/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.product_id' => [
'required',
'integer',
Rule::exists('products', 'id')->where('is_active', true),
],
'items.*.quantity' => 'required|integer|min:1|max:100',
'coupon_code' => ['nullable', 'string', new ValidCouponCode()],
'discount' => [
'sometimes',
'numeric',
'min:0',
function ($attribute, $value, $fail) {
if ($value > $this->input('subtotal', 0)) {
$fail('Discount cannot exceed subtotal.');
}
},
],
];
}
}
出力:
// 実行成功
6. エラーメッセージの処理
(1) Webページでのエラー表示
<!-- すべてのエラーを表示 -->
@if ($errors->any())
<div class="alert alert-error">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- 特定フィールドのエラーを表示 -->
<input type="text" name="name" value="{{ old('name') }}"
class="{{ $errors->has('name') ? 'border-red-500' : '' }}">
@error('name')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
(2) API JSONエラーレスポンス
{
"message": "The given data was invalid.",
"errors": {
"name": ["The name field is required."],
"email": ["The email must be a valid email address."]
}
}
| リクエスト種別 | バリデーション失敗時の動作 | フォーマット |
|---|---|---|
| Webフォーム | フォームにリダイレクト戻り + エラー表示 | $errorsビュー変数 |
| API JSON | 422 + JSON返却 | {"errors": {...}} |
| AJAX | 422 + JSON返却 | APIと同じ |
(1) ▶ サンプル:ShopMetrics APIのバリデーションエラー処理
// app/Http/Controllers/Api/ShopController.php
public function store(StoreShopRequest $request): JsonResponse
{
$shop = Shop::create($request->validated());
return response()->json([
'message' => 'Shop created successfully.',
'data' => new ShopResource($shop),
], 201);
}
// バリデーション失敗時, クライアントは422を受信:
// {
// "message": "The slug has already been taken.",
// "errors": {
// "slug": ["The slug has already been taken."]
// }
// }
出力:
// 実行成功
7. 条件バリデーション
(1) sometimesルール
// フィールドが存在する場合のみバリデーション
$validated = $request->validate([
'name' => 'required|string',
'notes' => 'sometimes|nullable|string|max:5000',
]);
// 別フィールドに基づく条件付きルール
Validator::make($data, [
'payment_method' => 'required|in:credit_card,bank_transfer',
'card_number' => 'required_if:payment_method,credit_card|numeric',
'bank_account' => 'required_if:payment_method,bank_transfer|numeric',
]);
(2) bailルール
// フィールドの最初の失敗後にバリデーションを停止
$validated = $request->validate([
'email' => 'bail|required|email|unique:users',
// requiredが失敗した場合, emailとuniqueは実行されない
]);
| ルール | 機能 | 使用場面 |
|---|---|---|
sometimes |
フィールドが存在する場合のみバリデーション | オプションフィールド |
bail |
最初の失敗後に停止 | 高コストなバリデーション |
required_if |
条件付き必須 | 支払方法 → カード番号 |
required_unless |
フィールドはオプション | — |
required_with |
必須 | パスワード確認 |
prohibited_if |
条件付き禁止 | — |
exclude_if |
条件付き除外 | validatedに書き込まない |
nullable |
nullを許可 | オプションフィールド |
(1) ▶ サンプル:ShopMetrics条件バリデーションシナリオ
// app/Http/Requests/UpdateSubscriptionRequest.php
class UpdateSubscriptionRequest extends FormRequest
{
public function rules(): array
{
return [
'plan_id' => 'required|exists:plans,id',
'payment_method' => 'required|in:credit_card,paypal,bank_transfer',
'card_number' => 'required_if:payment_method,credit_card|string|size:16',
'card_cvv' => 'required_if:payment_method,credit_card|string|size:3',
'paypal_email' => 'required_if:payment_method,paypal|email',
'bank_account' => 'required_if:payment_method,bank_transfer|string',
'coupon_code' => 'sometimes|nullable|string|max:50',
];
}
}
出力:
// 実行成功
8. 総合例:ShopMetrics完全バリデーションフロー
// ============================================
// 総合:ShopMetrics注文バリデーション
// カバー:フォームリクエスト, カスタムルール, 条件, エラー
// ============================================
// app/Rules/SufficientStock.php
class SufficientStock implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$productId = request()->input(str_replace('.quantity', '.product_id', $attribute));
$product = Product::find($productId);
if ($product && $value > $product->stock) {
$fail("Only {$product->stock} units available for {$product->name}.");
}
}
}
// app/Http/Requests/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'items' => 'required|array|min:1|max:50',
'items.*.product_id' => [
'required',
'integer',
Rule::exists('products', 'id')->where('is_active', true),
],
'items.*.quantity' => [
'required',
'integer',
'min:1',
'max:100',
new SufficientStock(),
],
'coupon_code' => 'sometimes|nullable|string|max:50',
'notes' => 'sometimes|nullable|string|max:1000',
'shipping_address.line1' => 'required|string|max:255',
'shipping_address.city' => 'required|string|max:100',
'shipping_address.zip' => 'required|string|max:20',
'shipping_address.country' => 'required|string|size:2',
];
}
public function messages(): array
{
return [
'items.required' => 'Your cart is empty.',
'items.min' => 'Add at least one item to place an order.',
'items.*.product_id.exists' => 'One of the selected products is unavailable.',
'shipping_address.line1.required' => 'Street address is required.',
];
}
}
// コントローラはスリムなまま
public function store(StoreOrderRequest $request): RedirectResponse
{
$order = $this->orderService->createFromRequest($request);
return redirect()->route('orders.show', $order)
->with('success', 'Order placed successfully!');
}
❓ よくある質問
validate()とフォームリクエストはどう使い分ければよいですか?validate()を直接使用し, 複雑なバリデーション (10ルール以上, 再利用性, 認可チェックが必要)の場合はフォームリクエストを使用します。フォームリクエストがベストプラクティスです。Rule::unique('shops', 'slug')->ignore($shop->id)またはunique:shops,slug,{shop}形式を使用してください。更新バリデーションでは現在のレコードを除外する必要があります。そうしないとバリデーションが常に失敗します。{"message":"...","errors":{"field":["エラーメッセージ"]}}。フロントエンドは422ステータスコードに基づいてerrorsオブジェクトを解析し, フィールドレベルのエラーを表示してください。prepareForValidationの目的は何ですか?validated()の出力で使用されます。$this->postJson()で無効なデータを送信し, 422エラーとエラーメッセージ$response->assertJsonValidationErrors('name')が返されることを確認してください。Validator::make()でルールを直接テストすることもできます。uniqueとexistsルールはデータベースをクエリするため, パフォーマンスオーバーヘッドがあります。高頻度APIではbailで早期終了するか, バリデーション結果をキャッシュすることを検討してください。ほとんどのシナリオではパフォーマンスは問題になりません。📖 まとめ
- Laravelバリデーションはデータがシステムに入る前にインターセプトし, 幅広いルールを組み合わせ可能です
- フォームリクエストクラスがバリデーションロジックをカプセル化し, コントローラをすっきり保ちます
- クロージャルールは1回きりのロジックに, ルールオブジェクトは再利用可能なロジックに適しています
- Webバリデーション失敗時はリダイレクトしてエラーメッセージを表示し, APIは422 JSONレスポンスを返します
- "sometimes"/nullable:オプションフィールドを処理し, "bail":早期終了します
- prepareForValidation:バリデーション前に入力データを変更します
📝 練習問題
-
基本問題 (⭐):ShopMetrics商品用の
StoreProductRequestを作成し,name,sku,price,stock,category_idのバリデーションルールを含め, SKUが一意で価格が正の数であることを確認してください。 -
応用問題 (⭐⭐):
ValidCouponCodeルールオブジェクトを作成し, クーポンが期限切れでなく, 残り使用回数があることを確認してください。このルールをStoreOrderRequestに適用し, 有効なクーポンと無効なクーポンのレスポンスをテストしてください。 -
チャレンジ (⭐⭐⭐):
StoreOrderRequestのitems.*.quantityフィールドにSufficientStockルール (注文数量が在庫を超えないことを確認)を実装し, 複数商品の同時注文時の在庫競合問題を処理してください。



