Laravelでカスタムバリデーションルールを作る:複雑な入力チェックを再利用可能にする実務設計
- 作成日 2026.02.25
- その他
Laravelのバリデーションは標準ルールだけでも強力だが、業務では「DBの状態を加味」「複数項目を横断」「外部APIで検証」「独自フォーマット」など、標準だけでは表現しきれないチェックが出てくる。カスタムルールに切り出すと、コントローラやFormRequestが肥大化せず、テストもしやすくなり、同じルールを複数画面で使い回せる。この記事は、Ruleクラス/クロージャ/Validator拡張の使い分け、エラーメッセージ、属性名、ローカライズ、テスト、よくある落とし穴までを整理する。
- 1. カスタムルールが必要になる条件:標準ルールで表現できない“業務ルール”
- 2. 実装パターンの全体像:Ruleクラス / クロージャ / Validator拡張
- 3. Ruleクラスを作る:make:rule で雛形を用意する
- 4. サンプル:メールのドメインを許可リストで制限するRule
- 5. FormRequestで使う:rules() にRuleインスタンスを渡す
- 6. クロージャルール:小さいチェックをその場で書く
- 7. 複数項目を跨ぐチェック:after() / withValidator() に寄せる
- 8. DB状態に依存するルール:クエリを入れるときの注意点
- 9. エラーメッセージ設計::attribute と attributes() を活用する
- 10. ローカライズ:resources/lang で文言を一元管理する
- 11. テスト:Rule単体テストとRequestテストを分ける
- 12. よくある落とし穴:想定外にルールが呼ばれる、例外で500になる
- 13. まとめ:カスタムバリデーションを運用で強くするチェックリスト
カスタムルールが必要になる条件:標準ルールで表現できない“業務ルール”
よくある条件:
・会員番号のチェックディジット(独自アルゴリズム)
・「開始日 < 終了日」のような相関チェック(複数項目)
・同じメールアドレスでも、同一企業ドメインだけ許可
・郵便番号と都道府県の整合、電話番号の国番号対応
・予約枠の重複確認(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の返り方も固定する
-
前の記事
Laravelで作るリアルタイムWebソケットアプリ:Reverb+Broadcasting実装パターン 2026.02.24
-
次の記事
記事がありません
コメントを書く