Laravelでカスタムバリデーションルールを作る:複雑な入力チェックを再利用可能にする実務設計

Laravelでカスタムバリデーションルールを作る:複雑な入力チェックを再利用可能にする実務設計

Laravelのバリデーションは標準ルールだけでも強力だが、業務では「DBの状態を加味」「複数項目を横断」「外部APIで検証」「独自フォーマット」など、標準だけでは表現しきれないチェックが出てくる。カスタムルールに切り出すと、コントローラやFormRequestが肥大化せず、テストもしやすくなり、同じルールを複数画面で使い回せる。この記事は、Ruleクラス/クロージャ/Validator拡張の使い分け、エラーメッセージ、属性名、ローカライズ、テスト、よくある落とし穴までを整理する。

カスタムルールが必要になる条件:標準ルールで表現できない“業務ルール”

よくある条件:
・会員番号のチェックディジット(独自アルゴリズム)
・「開始日 < 終了日」のような相関チェック(複数項目)
・同じメールアドレスでも、同一企業ドメインだけ許可
・郵便番号と都道府県の整合、電話番号の国番号対応
・予約枠の重複確認(DB状態を見て判断)
・ファイルの中身(CSVヘッダや行数)を検証
“入力値だけ”で判断できるものと、“DBや外部に依存するもの”で実装を分けると崩れにくい。

実装パターンの全体像:Ruleクラス / クロージャ / Validator拡張

使い分けの目安:
・Ruleクラス:再利用が多い、テストしたい、依存(設定/DB)がある
・クロージャ:その場限り、簡易チェック、ルールが増えない
・Validator拡張:既存の「ルール名」として横断的に提供したい(legacyを含む)
基本はRuleクラスが安定。

Ruleクラスを作る:make:rule で雛形を用意する

Ruleクラスは「合否判定」と「失敗時メッセージ」の責務を分離できる。

php artisan make:rule AllowedCompanyEmailDomain

サンプル:メールのドメインを許可リストで制限するRule

管理画面やB2Bサービスでよくある「特定ドメインのみ許可」。設定値に寄せると運用で強い。

// app/Rules/AllowedCompanyEmailDomain.php(例)
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class AllowedCompanyEmailDomain implements ValidationRule
{
    public function __construct(
        private array $allowedDomains
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || !str_contains($value, '@')) {
            $fail(':attribute の形式が正しくありません。');
            return;
        }

        $domain = strtolower(substr(strrchr($value, "@"), 1));

        if (!in_array($domain, array_map('strtolower', $this->allowedDomains), true)) {
            $fail(':attribute は許可されていないドメインです。');
        }
    }
}

FormRequestで使う:rules() にRuleインスタンスを渡す

FormRequestに寄せると、コントローラが薄くなる。

// app/Http/Requests/StoreUserRequest.php(例)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\AllowedCompanyEmailDomain;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email:rfc',
                new AllowedCompanyEmailDomain(['example.com', 'example.co.jp']),
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => 'メールアドレス',
        ];
    }
}

クロージャルール:小さいチェックをその場で書く

軽い相関チェックなどに便利。増えたらRuleクラスへ移す。

// app/Http/Requests/StoreUserRequest.php(例)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\AllowedCompanyEmailDomain;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email:rfc',
                new AllowedCompanyEmailDomain(['example.com', 'example.co.jp']),
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => 'メールアドレス',
        ];
    }
}

複数項目を跨ぐチェック:after() / withValidator() に寄せる

「AがこうならB必須」「3項目の組み合わせ」などは、項目ごとのrulesで無理に表現すると読みづらくなる。FormRequestでまとめて処理する方が保守しやすい。

// app/Http/Requests/StoreUserRequest.php(例)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\AllowedCompanyEmailDomain;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email:rfc',
                new AllowedCompanyEmailDomain(['example.com', 'example.co.jp']),
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => 'メールアドレス',
        ];
    }
}

DB状態に依存するルール:クエリを入れるときの注意点

予約枠の重複や在庫チェックなどはDB参照が必要。ここで詰まりやすい。
注意点:
・N+1(ルールが複数回呼ばれる)にならないように、必要なら事前にまとめて取得
・トランザクション中の整合(確定前に見えていない)
・並行実行での競合(バリデーション通過後に別リクエストが確保)
最終的な整合は「ユニーク制約」「ロック」「再検証」で守る。

// app/Http/Requests/StoreUserRequest.php(例)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\AllowedCompanyEmailDomain;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email:rfc',
                new AllowedCompanyEmailDomain(['example.com', 'example.co.jp']),
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => 'メールアドレス',
        ];
    }
}

エラーメッセージ設計::attribute と attributes() を活用する

Ruleクラスのfailメッセージに:attributeを入れると、属性名置換が効く。
・FormRequestのattributes()で日本語表示
・messages()で標準ルールの文言も統一
エラーをユーザー向けにするほど、問い合わせと運用負荷が減る。

ローカライズ:resources/lang で文言を一元管理する

Rule内で固定文字列を増やすと、あとで文言変更が大変。翻訳キーに寄せると変更が一括で済む。

// 例:fail(__('validation.custom.allowed_domain')) のようにキー参照
$fail(__('validation.custom.allowed_domain'));

テスト:Rule単体テストとRequestテストを分ける

壊れやすいのは「業務ルール」なので、ルール単体テストが効く。
・Rule単体:許可/不許可の入力を網羅
・Requestテスト:HTTPレイヤで422とエラーフィールドを確認
「動くけど想定外の入力を通す」を防ぐのが目的。

// Rule単体テスト(例:雰囲気)
public function test_allowed_domain_rule(): void
{
    $rule = new \App\Rules\AllowedCompanyEmailDomain(['example.com']);

    $validator = \Validator::make(
        ['email' => 'user@example.com'],
        ['email' => ['required', $rule]]
    );

    $this->assertFalse($validator->fails());
}

よくある落とし穴:想定外にルールが呼ばれる、例外で500になる

発生条件が多い箇所:
・nullable/requiredの組み合わせが不適切で、空値に対してルールが走る
・Rule内で例外(外部API失敗、DB接続不調)を投げて500になる
・パフォーマンス劣化(ルール内で毎回重いクエリ)
・メッセージが属性に紐づかず、どこが悪いか分からない
実務では「空値は先に弾く」「例外はfailに落とす」「重い処理は避ける」が効く。

まとめ:カスタムバリデーションを運用で強くするチェックリスト

・再利用するならRuleクラス、単発ならクロージャ、横断提供ならValidator拡張
・相関チェックはFormRequestのafter/withValidatorに寄せる
・DB依存は最終整合をDB制約/ロックで守る前提にする
・:attribute と attributes() でユーザー向け文言を統一
・ルール単体テストで想定入力を網羅し、422の返り方も固定する