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の設計においても重要な役割を持つクラスなので、積極的に活用していきたいですね。