Laravel バリデーション設計でFormRequestと業務ルールを分ける考え方

Laravel バリデーション 設計でFormRequestと業務ルールを分ける図

Laravelの入力チェックはどこまでFormRequestに置くべきか

Laravel バリデーション 設計で実務上の問題になりやすいのは、FormRequestに何でも書くか、逆にControllerへ条件分岐を残しすぎることです。

結論から言うと、FormRequestは入力形式の検証に寄せます。業務ルールはService層で扱います。そして、DB制約は最後の防衛線として設計します。

もちろん、Laravelのバリデーション機能は強力です。例えば、必須、文字数、形式、配列、unique、existsなど、多くの入力チェックを簡潔に書けます。

ただし、便利だからといって、すべての判断をFormRequestへ集めると保守しにくくなります。申込期限、在庫状態、権限、状態遷移のような判断は、入力形式ではなく業務ルールです。

この記事では、LaravelでAPIやフォームを実装する経験者向けに、FormRequest、Controller、Service層、DB制約の責務分担を整理します。

Laravel バリデーション設計の基本は責務を分けること

まず、バリデーションを「不正な値を弾く処理」とだけ見ると、設計判断が曖昧になります。そこで、入力形式、業務判断、永続化の安全性に分けて考えます。

置き場所主な責務レビュー観点
FormRequestリクエスト入力の形式チェック必須、型、文字数、メール形式、配列構造DBや複雑な業務判断を持ちすぎていないか
Controllerリクエスト受け付けとレスポンス返却FormRequestを受け取り、Serviceを呼ぶif文で業務ルールを抱えていないか
Service層業務ルールとユースケース制御申込可能期間、状態遷移、権限、重複登録判断入力形式チェックと混ざっていないか
DB制約データ整合性の最終防衛線NOT NULL、UNIQUE、外部キー、CHECK制約アプリだけに整合性を任せていないか

この分け方にすると、レビュー時の会話が具体的になります。「このルールはFormRequestかServiceか」という議論ではなく、「入力形式なのか、業務判断なのか」を確認できます。

なお、Laravel公式のValidationドキュメントでは、FormRequest、カスタムメッセージ、validated input、配列バリデーションなどが整理されています。実装方法を確認するときは、まず公式の仕様に戻るのが安全です。

FormRequestに置く入力チェック

FormRequestに置きやすいのは、リクエスト単体で判断できる入力チェックです。つまり、DBの状態や現在の業務ステータスを深く見なくても判断できる条件です。

  • 必須項目かどうか
  • 文字数、数値範囲、日付形式が妥当か
  • メールアドレスやURLの形式が正しいか
  • 配列やネストした項目の構造が正しいか
  • 列挙値として許可された値か

例えば、ユーザー登録APIなら、名前、メールアドレス、パスワードの形式はFormRequestで扱います。一方で、登録キャンペーン期間中かどうか、特定プランを選べるかどうかは業務ルールです。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreMemberRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email', 'max:255'],
            'plan' => ['required', Rule::in(['standard', 'premium'])],
            'started_at' => ['required', 'date'],
        ];
    }

    public function messages(): array
    {
        return [
            'email.email' => 'メールアドレスの形式で入力してください。',
            'plan.in' => '選択できないプランが指定されています。',
        ];
    }
}

この例では、FormRequestが入力の形を整えています。Service層に渡る前に、最低限のデータ品質を保証できます。

ただし、ここに「現在の契約状態ではpremiumへ変更できない」といった判断を入れると、FormRequestが業務仕様を抱え始めます。その結果、同じルールをバッチ、管理画面、別APIで再利用しにくくなります。

Controllerはバリデーション結果を受け取るだけに近づける

次に、Controllerは薄く保ちます。LaravelではController内で`$request->validate()`を書くこともできますが、実務では処理が増えるほどControllerが読みにくくなります。

そのため、更新系APIや業務画面ではFormRequestを使い、Controllerは入力済みデータをService層へ渡す形に寄せます。これにより、リクエスト受付、業務処理、レスポンス返却の流れが見やすくなります。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreMemberRequest;
use App\Services\MemberRegistrationService;
use Illuminate\Http\JsonResponse;

class MemberController extends Controller
{
    public function store(
        StoreMemberRequest $request,
        MemberRegistrationService $service
    ): JsonResponse {
        $member = $service->register($request->validated());

        return response()->json([
            'id' => $member->id,
            'name' => $member->name,
        ], 201);
    }
}

このControllerなら、レビュー時に見るポイントが明確です。入力チェックはFormRequest、登録可否の判断はService層、レスポンス形式はControllerという切り分けになります。

また、Laravel公式のHTTP Requestsドキュメントも、リクエストから入力を取得する方法を確認する際に役立ちます。FormRequestで検証済みデータを扱う場合も、入力の扱い方をチームでそろえておくと安全です。

業務ルールはService層で明示する

一方で、業務ルールはService層に置く方が自然です。理由は、業務ルールはリクエストの形式ではなく、現在の状態や操作の意味に依存するからです。

例えば、次のような条件はFormRequestに押し込まない方が保守しやすくなります。

  • 申込期間が終了しているか
  • 現在のステータスから更新できるか
  • ログインユーザーが対象データを操作できるか
  • 同じ契約で重複申込にならないか
  • 在庫、残高、利用上限を超えていないか
<?php

namespace App\Services;

use App\Exceptions\BusinessRuleException;
use App\Models\Member;
use App\Models\Plan;

class MemberRegistrationService
{
    public function register(array $input): Member
    {
        $plan = Plan::where('code', $input['plan'])->firstOrFail();

        if (! $plan->isAcceptingApplications()) {
            throw new BusinessRuleException('このプランは現在申し込みできません。');
        }

        if (Member::where('email', $input['email'])->exists()) {
            throw new BusinessRuleException('このメールアドレスは既に登録されています。');
        }

        return Member::create([
            'name' => $input['name'],
            'email' => $input['email'],
            'plan_id' => $plan->id,
            'started_at' => $input['started_at'],
        ]);
    }
}

このように分けると、業務ルールに名前を付けやすくなります。さらに、同じ登録処理をAPI以外から呼ぶ場合でも、ルールを再利用しやすくなります。

ただし、Service層が巨大化する場合もあります。その場合は、業務ルールをDomain Service、Policy、Specification、専用の判定クラスへ分けることを検討します。

DB制約はLaravel バリデーション設計の最後の防衛線

Laravelのバリデーションがあるからといって、DB制約を省略するのは危険です。アプリケーション側のチェックは、同時実行、別経路の登録、将来の改修で抜ける可能性があります。

特に、NOT NULL、UNIQUE、外部キーは、アプリケーションの都合ではなくデータ整合性の要件として見るべきです。つまり、FormRequestやService層で確認していても、DBでも守る価値があります。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('members', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->string('email')->unique();
            $table->foreignId('plan_id')->constrained();
            $table->date('started_at');
            $table->timestamps();
        });
    }
};

なお、Laravel公式のMigrationsドキュメントでは、カラム定義、インデックス、外部キー制約などが説明されています。DB制約は、バリデーションの代替ではなく、整合性を守る別の層として設計します。

一方で、DB制約だけではユーザーにわかりやすいエラーメッセージを返せません。そのため、画面やAPIではFormRequestとService層で先に意味のあるエラーを返し、DB制約で最終的に守る形が現実的です。

エラーメッセージは入力エラーと業務エラーで分ける

エラーメッセージも、責務を分けて設計します。入力エラーと業務エラーを同じ形式で返すと、フロントエンド側で扱いにくくなることがあります。

エラー種別返し方の考え方
入力エラーemailが不正、nameが未入力field単位でerrorsに返す
業務エラー申込期間外、状態変更不可業務コードと表示メッセージを返す
一意制約違反同じメールが同時登録されたServiceで検出しつつ、DB例外も考慮する
システムエラーDB障害、想定外例外詳細を返さずログと監視で追う

例えば、APIではFormRequestのバリデーション失敗を422として返すことが多いです。一方で、「この注文は既に確定済みです」という業務エラーは、field単位の入力エラーではありません。

そのため、フロントエンドとAPIの間でエラー形式を決めておく必要があります。入力エラーはフォーム項目へ表示します。業務エラーは画面全体の通知や確認ダイアログへつなげます。

実務レビューで見たいLaravelバリデーション設計の観点

レビューでは、ルールが正しいかだけでなく、置き場所が適切かを確認します。特に、変更が入ったときにどこを直せばよいかが重要です。

  • FormRequestに業務ルールが入りすぎていないか
  • Controllerが条件分岐で肥大化していないか
  • Service層で状態遷移や権限の判断が明示されているか
  • DB制約がデータ整合性の最後の防衛線になっているか
  • 入力エラーと業務エラーのレスポンス形式が混ざっていないか
  • エラーメッセージがユーザーの次の操作につながるか
  • テストでFormRequestとService層のルールを分けて確認できるか

また、入力チェックの追加が多い画面では、FormRequestの`rules()`だけでなく、テストの読みやすさも見ます。ルールが増えたときに、何を守りたいのかがテスト名から分かると保守しやすくなります。

さらに、uniqueやexistsを使う場合は、DBアクセスが増えます。大量項目や配列入力では、クエリ数やパフォーマンスも確認対象になります。

よくある質問

FormRequestにDB参照を書くのは避けるべきですか?

すべて避ける必要はありません。Laravelのuniqueやexistsのように、入力値が既存データと対応するかを見るルールは実務で使われます。

ただし、状態遷移、申込可否、権限、在庫のように業務判断を含む場合はService層へ寄せます。FormRequestは入力形式の検証を中心にする方が、責務が読みやすくなります。

Controllerでvalidateしても問題ありませんか?

小さい処理なら問題ない場合もあります。例えば、単純な検索フォームや一時的な管理機能ではController内のvalidateでも十分です。

一方で、更新系APIや業務ルールが増える画面ではFormRequestへ分けます。Controllerに入力チェック、業務判断、レスポンス整形が集まると、変更時の影響範囲が見えにくくなります。

DB制約があればFormRequestは不要ですか?

不要ではありません。DB制約はデータ整合性を守る最後の仕組みです。一方で、FormRequestはユーザーやAPIクライアントへ早く分かりやすくエラーを返すために使います。

つまり、FormRequest、Service層、DB制約は競合しません。役割が違うため、重ねて設計する方が安全です。

まとめ:Laravel バリデーション設計は入力形式と業務判断を分ける

Laravel バリデーション設計では、FormRequestを使うかどうかだけで判断しないことが重要です。まず、入力形式のチェックと業務ルールを分けます。

次に、Controllerを薄くし、Service層で業務判断を明示します。さらに、DB制約でデータ整合性を守ります。

この分け方にすると、レビューしやすくなります。また、API、フォーム、バッチ、管理画面など、複数の入口が増えても同じ業務ルールを扱いやすくなります。

Laravel/API設計の案件では、バリデーションの置き場所が保守性に直結します。FormRequest、Service層、DB制約の責務分担を意識してきた経験は、業務系WebシステムやAPI開発でも活かしやすいです。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア