Laravelには、フォーム入力を検証するための FormRequest クラスが用意されています。
バリデーションをコントローラから切り離して FormRequest にまとめることで、再利用性・可読性・保守性がぐっと上がります。

Controller と FormRequest の役割

Controller の役割(司令塔)

  • リクエストの入口として何をどう処理するかを決める
  • Model を呼び出して保存・更新・削除などを行う
  • View を返す、または APIレスポンスを組み立てる

何でもかんでも詰め込むとコントローラはすぐに肥大化します。

FormRequest の役割(入力と権限のゲート)

  • 入力バリデーションを集中管理
  • 必要であれば authorize() で権限チェックも行う
  • 同じ検証ロジックを複数のコントローラ/アクションで再利用可能

分離のメリット

  • 可読性:コントローラは「フロー決定」に専念してスリムになる
  • 再利用性:同じバリデーションを横展開可能
  • テスト容易性:FormRequest単体で検証ルールをテストできる
  • 保守性:仕様変更時の修正箇所を最小化する

FormRequest の主な役割

役割 記載する場所 内容・ポイント
実行可否(権限) authorize() 現在のユーザーがこの操作をしてよいかを判定
入力ルール定義 rules() required, email, max, unique, Rule クラス等で入力仕様を宣言
エラーメッセージ messages() バリデーション文言をこのリクエスト専用に上書き
フィールド名表記 attributes() エラー表示時の見出し名を変換(例:user_email →「メールアドレス」)
事前整形・前処理 prepareForValidation() トリム、全角→半角、型変換等を $this->merge() で注入(例:$this->merge([‘id’ => $this->route(‘id’)]);)
条件付きルール追加 withValidator($validator) 入力に応じた条件付きルールや相関チェックを追加
事後処理(検証通過後) passedValidation() 正常時のみ走るフック。DTO生成、追加の整形、ログなどに

 

FormRequest の記載方法

ここでは例として、よくある入力フォームをbladeに記載します。

@section('content')
<div class="container">

    <h1>商品登録</h1>

    {{-- バリデーションエラーがあった場合は一覧でここに表示 --}}
    @if ($errors->any())
        <div class="alert alert-danger">
            <p>入力内容に問題があります。確認してください。</p>
            <ul>
                @foreach ($errors->all() as $m)
                    <li>{{ $m }}</li>
                @endforeach
            </ul>
        </div>
    @endif

    <form method="POST" action="{{ route('products.store') }}">
        @csrf

        {{-- カテゴリー --}}
        <div class="mb-3">
            <label for="category_id" class="form-label">カテゴリー</label>
            <select id="category_id" name="category_id" class="form-select">
                <option value="">選択してください</option>
                @foreach ($categories as $id => $label)
                    <option value="{{ $id }}" @selected(old('category_id') == $id)>{{ $label }}</option>
                @endforeach
            </select>
            @error('category_id') <div class="text-danger small">{{ $message }}</div> @enderror
        </div>

        {{-- 商品名 --}}
        <div class="mb-3">
            <label for="name" class="form-label">商品名</label>
            <input id="name" type="text" name="name" class="form-control" value="{{ old('name') }}" maxlength="50">
            @error('name') <div class="text-danger small">{{ $message }}</div> @enderror
        </div>

        {{-- 商品タイプ(Enum) --}}
        <div class="mb-3">
            <label for="product_type" class="form-label">商品タイプ</label>
            <select id="product_type" name="product_type" class="form-select">
                <option value="">選択してください</option>
                @foreach (\App\Enums\ProductType::cases() as $pt)
                    <option value="{{ $pt->value }}" @selected(old('product_type') == $pt->value)>
                        {{ $pt->name }}
                    </option>
                @endforeach
            </select>
            @error('product_type') <div class="text-danger small">{{ $message }}</div> @enderror
        </div>

        {{-- 期間 --}}
        <div class="row g-3 mb-3">
            <div class="col">
                <label for="start_date" class="form-label">開始日(YYYY/MM/DD)</label>
                <input id="start_date" type="text" name="start_date" class="form-control"
                       placeholder="2025/09/19" value="{{ old('start_date') }}">
                @error('start_date') <div class="text-danger small">{{ $message }}</div> @enderror
            </div>
            <div class="col">
                <label for="end_date" class="form-label">終了日(YYYY/MM/DD)</label>
                <input id="end_date" type="text" name="end_date" class="form-control"
                       placeholder="2025/09/30" value="{{ old('end_date') }}">
                @error('end_date') <div class="text-danger small">{{ $message }}</div> @enderror
            </div>
        </div>

        {{-- サイズ配列 --}}
        <div class="mb-3">
            <label class="form-label">サイズ</label>

            @php
                $rows = old('data', [['size_id' => '', 'size_name' => '']]);
            @endphp

            <div id="size-rows">
                @foreach ($rows as $i => $row)
                    <div class="row g-2 align-items-end mb-2 size-row">
                        <div class="col-5">
                            <label class="form-label">サイズID</label>
                            <select name="data[{{ $i }}][size_id]" class="form-select">
                                <option value="">選択してください</option>
                                @foreach ($sizes as $sid => $sname)
                                    <option value="{{ $sid }}" @selected(old("data.$i.size_id", $row['size_id']) == $sid)>
                                        {{ $sid }} : {{ $sname }}
                                    </option>
                                @endforeach
                            </select>
                            @error("data.$i.size_id") <div class="text-danger small">{{ $message }}</div> @enderror
                        </div>
                        <div class="col-5">
                            <label class="form-label">サイズ名</label>
                            <input type="text" name="data[{{ $i }}][size_name]" class="form-control"
                                   value="{{ old("data.$i.size_name", $row['size_name']) }}">
                            @error("data.$i.size_name") <div class="text-danger small">{{ $message }}</div> @enderror
                        </div>
                        <div class="col-2">
                            <button type="button" class="btn btn-outline-secondary w-100 remove-row">削除</button>
                        </div>
                    </div>
                @endforeach
            </div>

            <button type="button" id="add-size" class="btn btn-outline-primary">+ 行を追加</button>

            {{-- 配列全体のエラー(カスタムバリデーション等) --}}
            @error('data') <div class="text-danger small mt-2">{{ $message }}</div> @enderror
            @error('data.*') <div class="text-danger small mt-2">{{ $message }}</div> @enderror
        </div>

        <div class="mt-4">
            <button class="btn btn-primary" type="submit">登録する</button>
            <a href="{{ url()->previous() }}" class="btn btn-link">戻る</a>
        </div>
    </form>
</div>
@endsection

FormRequest を作成します。

php artisan make:request StoreRequest

作成された FormRequest に各項目のバリデーションルールを追記していきます。

class StoreRequest extends CustomRequest
{
    /**
     * 認証
     * @return bool
     */
    public function authorize(): bool
    {
        return true; // 必要に応じて Gate/Policy へ
    }

    /**
     *  バリデーション項目名定義
     * @return array
     */
    public function attributes()
    {
        return [
            'category_id' => 'カテゴリーID',
            'name' => '商品名',
            'product_type' => '商品タイプ',
            'data.*.size_id' => 'サイズID',
            'data.*.size_name' => 'サイズ名',
            'start_date' => '開始日',
            'end_date' => '終了日',
        ];
    }

    /**
     * カスタムバリデーションメッセージ
     * @return array
     */
    public function messages()
    {
        return [
            'start_date.after' => '開始日は、現在日より後の日付を指定してください。',
            'data.min' => 'サイズは1件以上指定してください。',
        ];
    }

    /**
     * バリデーションルール
     * @return array
     */
    public function rules()
    {
        $rules = [
            // categoriesテーブルのidが存在するかどうかをチェック
            'category_id' => ['required', 'integer', 'exists:categories,id'],
            // 商品名の重複チェック
            'name' => [
                'required',
                'string',
                'max:50',
                Rule::unique('products', 'name')->where(function ($query) {
                    // 未削除のレコードで検証する
                    $query->whereNull('deleted_at');
                }),
            ],
            // ProductTypeのenumに存在する値かどうかをチェック
            'product_type' => ['required', 'integer', Rule::enum(ProductType::class)],
            // 配列の中身が存在するかどうかをチェック
            'data' => ['required', 'array'],
            // カスタムバリデーションでサイズの数が不正でないかをチェック
            'data.*' => ['required', 'array', $this->checkSize()],
            'data.*.size_id' => ['required', 'integer'],
            'data.*.size_name' => ['required', 'string'],
            // 日付のチェック
            'start_date' => ['required', 'date_format:Y/m/d', 'before_or_equal:end_date', 'after:today'],
            'end_date' => ['required', 'date_format:Y/m/d', 'after_or_equal:start_date'],

        ];

        return $rules;
    }

    /**
     * カスタムバリデーション
     */
    private function checkSize(): callable
    {
        return function ($attribute, $value, $fail) {
            // 配列の中身のsize_idを取得
            $sizeIds = collect($value)->pluck('size_id')->filter();
            // Sizeテーブルに存在するIDを取得
            $sizeList = Size::whereIn('id', $sizeIds)->get();
            // 取得件数と配列の件数が異なればエラー
            if ($sizeIds->count() !== $sizeList->count()) {
                $fail("サイズの数が不正です。");
            }
        };
    }
}

コントローラで FormRequest を適用すると、検証を通った項目だけを使うことが可能になります。

class ProductController extends Controller
{
    public function store(StoreRequest $request): RedirectResponse
    {
        // 検証済みデータ
        $data = $request->validated();

        // 登録処理

        return to_route('products.show', $data)
        ->with('status', '作成しました');
    }
}

ControllerとFormRequestそれぞれが責任範囲を明確に分担することで、コードがシンプルでメンテナンスしやすい形に保たれます。
その結果、テスト効率が上がり、改修コストや不具合発生のリスクが下がります。
Laravelの設計においても重要な役割を持つクラスなので、積極的に活用していきたいですね。