404 Not Found

404 Not Found


nginx

Laravel フォームバリデーション

バリデーションはLaravelの「セキュリティチェックポイント」です。システムに入るすべてのデータはバリデーションを通過し, 1つでも不正なデータが漏れることはありません。

1. 学ぶ内容


2. セキュリティ監査員からの実話

(1) ペインポイント:ユーザー入力によるデータ不整合

AliceがShopMetricsに登録した際, メールアドレスに"abc", 電話番号に"123"と入力し, 店舗名は空白のままでした。システムはすべてを受け入れてしまい, データベースには無効なデータが溢れました。悪意のあるユーザーがAPI経由でBobの店舗に負の価格の商品を送信し, 注文金額が-999ドルとなり, 財務レポートがクラッシュしました。セキュリティ監査で, Charlieは17のエンドポイントに一切入力バリデーションがないことを発見しました。

(2) Laravelバリデーションのソリューション

Laravelバリデーションはデータがシステムに入る前にインターセプトします。1つのルール配列で全バリデーションロジックをカバーし, FormRequestクラスがコントローラをすっきり保ちます。

PHP
// バリデーションルール — 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) バリデーションパイプライン処理

100%
flowchart TD
    A[リクエスト入力] --> B[バリデーションルール]
    B -->|通過| C[サニタイズ済みデータ]
    C --> D[コントローラロジック]
    B -->|失敗| E[エラー付きでリダイレクト戻り]
    E --> F["ビューで\$errorsを表示"]

(1) ▶ サンプル:ShopMetrics商品作成バリデーション

PHP
// コントローラ内のインラインバリデーション
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.');
}

出力:

TEXT
// 実行成功

4. フォームリクエストバリデーションクラス

(1) フォームリクエストの作成

BASH
php artisan make:request StoreShopRequest
php artisan make:request UpdateShopRequest

(2) ルールと認可の定義

PHP
// 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) コントローラでの使用

PHP
public function store(StoreShopRequest $request): RedirectResponse
{
    // $request->validated()はバリデーション済みデータのみ含む
    $shop = Shop::create($request->validated());
    return redirect()->route('shops.show', $shop);
}
項目 インラインバリデーション フォームリクエスト
場所 コントローラメソッド内 別クラスファイル
再利用性 低い ✅ 複数コントローラで再利用可能
認可チェック 手動 ✅ authorize()
コントローラコード 長くなる スリム
適用場面 単純なバリデーション 複雑/再利用可能なバリデーション

(1) ▶ サンプル:ShopMetrics StoreShopRequest

PHP
// 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),
        ]);
    }
}

出力:

TEXT
// 実行成功

5. カスタムバリデーションルール

(1) クロージャルール

PHP
// インラインカスタムルール
$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) ルールオブジェクト

BASH
php artisan make:rule ValidCouponCode
PHP
// 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クラスメソッド

PHP
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注文バリデーションルール

PHP
// 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.');
                    }
                },
            ],
        ];
    }
}

出力:

TEXT
// 実行成功

6. エラーメッセージの処理

(1) Webページでのエラー表示

HTML
<!-- すべてのエラーを表示 -->
@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エラーレスポンス

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のバリデーションエラー処理

PHP
// 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."]
//   }
// }

出力:

TEXT
// 実行成功

7. 条件バリデーション

(1) sometimesルール

PHP
// フィールドが存在する場合のみバリデーション
$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ルール

PHP
// フィールドの最初の失敗後にバリデーションを停止
$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条件バリデーションシナリオ

PHP
// 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',
        ];
    }
}

出力:

TEXT
// 実行成功

8. 総合例:ShopMetrics完全バリデーションフロー

PHP
// ============================================
// 総合: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!');
}

❓ よくある質問

Q validate()フォームリクエストはどう使い分ければよいですか?
A 単純なバリデーション (3〜5ルール)の場合はvalidate()を直接使用し, 複雑なバリデーション (10ルール以上, 再利用性, 認可チェックが必要)の場合はフォームリクエストを使用します。フォームリクエストがベストプラクティスです。
Q "unique"ルールで更新時に現在のレコードを除外するにはどうすればよいですか?
A Rule::unique('shops', 'slug')->ignore($shop->id)またはunique:shops,slug,{shop}形式を使用してください。更新バリデーションでは現在のレコードを除外する必要があります。そうしないとバリデーションが常に失敗します。
Q APIバリデーション失敗時の返却フォーマットは何ですか?
A Laravelは自動的に422ステータスコード + JSONを返します:{"message":"...","errors":{"field":["エラーメッセージ"]}}。フロントエンドは422ステータスコードに基づいてerrorsオブジェクトを解析し, フィールドレベルのエラーを表示してください。
Q prepareForValidationの目的は何ですか?
A バリデーション前に入力データを変更します。例えば, slugの自動生成, 電話番号のフォーマット, デフォルト値の追加などです。変更されたデータはバリデーションプロセスとvalidated()の出力で使用されます。
Q テスト中にバリデーションルールをテストするにはどうすればよいですか?
A $this->postJson()で無効なデータを送信し, 422エラーとエラーメッセージ$response->assertJsonValidationErrors('name')が返されることを確認してください。Validator::make()でルールを直接テストすることもできます。
Q バリデーションルールが多すぎるとパフォーマンスに影響しますか?
A uniqueexistsルールはデータベースをクエリするため, パフォーマンスオーバーヘッドがあります。高頻度APIではbailで早期終了するか, バリデーション結果をキャッシュすることを検討してください。ほとんどのシナリオではパフォーマンスは問題になりません。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetrics商品用のStoreProductRequestを作成し, name, sku, price, stock, category_idのバリデーションルールを含め, SKUが一意で価格が正の数であることを確認してください。

  2. 応用問題 (⭐⭐):ValidCouponCodeルールオブジェクトを作成し, クーポンが期限切れでなく, 残り使用回数があることを確認してください。このルールをStoreOrderRequestに適用し, 有効なクーポンと無効なクーポンのレスポンスをテストしてください。

  3. チャレンジ (⭐⭐⭐):StoreOrderRequestitems.*.quantityフィールドにSufficientStockルール (注文数量が在庫を超えないことを確認)を実装し, 複数商品の同時注文時の在庫競合問題を処理してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%