LaravelのValidationのカスタマイズ
Laravelのバリデーションは非常に便利ですが、デフォルトで用意されているルールではbeforeなどの一部を除き、ふたつ以上の入力値を比較してエラーを発生させことができません。
そこで、独自のルールを作成し、ふたつの入力の比較結果によるエラーチェックをバリデーションに組み込んでみたいと思います。
サンプルとして用意したシナリオは、「date_from(開始日)」と「date_to(終了日)」のふたつの日付を比較してその期間が30日を超えた場合に「期間エラー」をとするものです。
デフォルト設定で対応できるケース
まず、既存の書式で対応できるケースを紹介しておきます。他のフィールドが存在する(値がnull以外)の場合にチェックを除外させるexclude_withやその反対のexclude_withoutがあります。また、指定したフィールドが特定の値だった場合にチェックを除外させる方法exclude_ifやその反対のexclude_unlessなどがあります。
複数のフィールド間に存在する条件がシンプルならこれらを使うことで、ある程度の複数値判定ができます。
たとえば、次の場合「payment」フィールドがfalse値なら正規表現チェックをせずに通過させます。
ちなみに、exclude_ifやunlessで指定するフィールドは自分自身にする事もできます。それを利用して入力があった時(nullでない時)だけ、チェックを行うことが可能です。
カスタムルールの作成
複数のフィールド間の複雑な関係をチェックするにはカスタムルールを作成する必要があります。ここではPeriodという名前のカスタムルールを作成しました。
PS> php artisan make:rule Period --invokable
App¥Rulesフォルダ内に指定した名前でファイルが生成されます。--invokableオプションは呼び出し可能メソッドを生成するものでデフォルトだと次のようになっていると思います。
Period.php
<?php
namespace App¥Rules;
use Illuminate¥Contracts¥Validation¥InvokableRule;
class Period implements InvokableRule{
public function __invoke($attribute, $value, $fail){
}
}
__invokeメソッドはValidatorによって呼び出されます。引数には、属性名(キー名称)と、値、失敗時のコールバック関数が入ります。この関数の中でルールを定義します。
$failのコールバック関数を呼ぶとチェックでエラーとなった事を示す例外がスローされます。逆にこれが呼ばれずに関数が終了すればチェックは成功ということになります。
関数$failを呼ぶとき、第1引数にエラーメッセージを渡します。
とりあえず、値がnullだった場合にエラーとなるコードを書いておきます。
public function __invoke($attribute, $value, $fail){
if ($value===null) {
$fail('NULLがセットされています');
}
}
作成したカスタムルールのクラスのインスタンスを、Validatorの引数として渡すことで利用できます。
...
use App¥Rules¥Period;
$request->validate([
'ymd' => [new Period()]
...
カスタムルールのテスト
カスタムルールをテストしてみたいと思います。テスト用クラスを生成します。これはtests¥Futureフォルダに生成されます。
PS> php artisan make:test PeriodTest
テスト用のコードは次のようにします。POSTとして値を受け取らないので手動でキーと値を作ります。
また、合わせてValidator::makeを使ってValidatorも自作しています。このインスタンスではvalidateメソッドを呼ぶことでチェックを実行しますが、Requestオブジェクトから呼び出した時とは違って旧値の保存やリダイレクトは行われません。
Period.php
...
use App\Rules\Period;
use Illuminate\Support\Facades\Validator;
...
class PeriodTest extends TestCase{
public function test_example1(){
//サンプル値を生成
$params = ['date_from' => '2022-07-12'];
//手動でValidatorを作成してチェックを実行
Validator::make($params,['date_from'=>[new Period]])->validate();
//テストに対してpassの結果を返す
$this->assertTrue(true);
}
public function test_example2(){
$params = ['date_from' => null];
//先のルールに基づき例外が投げられてここでテストは失敗します
Validator::make($params,['date_from'=>[new Period]])->validate();
$this->assertTrue(true);
}
}
作成したテストを実行します。コード内のコメントにもありますが、test_example2の方は想定通りエラーとなりますので、成功1、失敗1という結果になります。
PS> php artisan test tests¥Feature¥PeriodTest.php
...
Tests: 1 failed, 1 passed
Time: 0.13s
他のデータへのアクセス
次に検証中の他のデータにアクセスするために、Ruleクラスを修正します。
DataAwareRuleをimplementsすると、setDataが自動で呼び出され、第1引数($data)にValidatorに渡された全てのデータが入ります。ここではこれをプロパティとして保持しておくことで後から利用可能にしています。
Period.php
<?php
namespace App¥Rules;
...
use Illuminate¥Contracts¥Validation¥DataAwareRule;
use Illuminate¥Contracts¥Validation¥ValidatorAwareRule;
use Illuminate¥Contracts¥Validation¥InvokableRule;
class Period implements InvokableRule, DataAwareRule, ValidatorAwareRule{
protected $data = [];
protected $validator;
public function setData($data){
$this->data = $data;
return $this;
}
public function setValidator($validator){
$this->validator = $validator;
return $this;
}
public function __invoke($attribute, $value, $fail){
if ($value===null) {
$fail('NULLがセットされています');
}
}
}
上記のコードではDataAwareRuleの他、ValidatorAwareRuleをimplementsし、setValidatorメソッド内で自身のインスタンスをプロパティに受け取っています。setValidatorメソッドは自動で実行されます。このように設定されたインスタンスでは、Validator自身のインスタンス内を参照することができるので、Validationの結果も取得できるようになります。
2値を比較してエラーを発生させるルールのサンプル
以上のことを利用して前述の条件を満たすRuleを完成させると次のようになります。
オリジナルのRuleはdate_toの最終段階でチェックを行う前提です。date_fromのキー名と期間の上限値をコンストラクタとして設定します。date_fromかdate_toでエラーが発生していた場合は、それを優先することにして期間のチェックをしません。これは日付形式ではないデータが入っていた場合には期間の導出ができないためです。
Period.php
<?php
namespace App¥Rules;
use Carbon¥Carbon;
use Illuminate¥Contracts¥Validation¥DataAwareRule;
use Illuminate¥Contracts¥Validation¥InvokableRule;
use Illuminate¥Contracts¥Validation¥ValidatorAwareRule;
class Period implements InvokableRule, DataAwareRule, ValidatorAwareRule{
protected $data = [];
protected $validator;
protected $date_from_key='';
protected $limitDays = 0;
public function __construct($date_from_key,$limitDays) {
//対象となる開始日のキーと、制限したい日数をセットする
$this->date_from_key = $date_from_key;
$this->limitDays = $limitDays;
}
public function setData($data) {
$this->data = $data;
}
public function setValidator($validator) {
$this->validator = $validator;
}
function __invoke($attribute, $value, $fail){
//ここに来るときdateチェックは行われている前提です
//失敗しているキーを取得
$failed = $this->validator->failed();
//date_fromで他のエラーが出ていた場合は期間チェックをしない
if (array_key_exists($this->date_from_key,$failed)) {
return;
}
//date_toで他のエラーが出ていた場合は期間チェックをしない
if (array_key_exists($attribute,$failed)) {
return;
}
$dateFrom = Carbon::createFromFormat('YmdHis',$this->data[$this->date_from_key]."000000");
$dateTo = Carbon::createFromFormat('YmdHis',$this->data[$attribute]."000000");
if ($this->limitDays < $dateFrom->diffInDays($dateTo)) {
$fail('日付範囲は '.$this->limitDays.'日以内にしてください');
}
}
}
ルールのテストと、Validatorの自作
さきほど作成したルールをテストしたいと思います。そのままコントローラーに配置してもいいのですがここではテスト機能を使います。テストは次のようなコードを作成しました。リクエスト部分は省略して、直にテスト用のデータを変数として記述しています。リクエストインスタンスがないので、通常はリクエストのメソッドから意識せずに利用しているValidatorも自作します。
ただ、自作といってもValidatorファサードのメソッドに、データとルールを渡すだけです。Validatorを自作するとより柔軟にエラー処理ができるようになります。
PeriodTest.php
<?php
namespace Tests¥Feature;
use App¥Rules¥Period;
use Exception;
use Illuminate¥Support¥Facades¥Validator;
use Illuminate¥Support¥Carbon;
use Tests¥TestCase;
class PeriodTest extends TestCase{
public function test_example1(){
$params = [
'date_from' => Carbon::today()->addDays(-30)->format('Ymd'),
'date_to' => Carbon::today()->format('Ymd'),
];
// Validatorの生成
$v = Validator::make($params,[
//fromは日付の整合性のみ
'date_from'=>['date'],
//toは日付の整合性と、from以降、期間のチェック
'date_to'=>['date','after_or_equal:date_from',new Period('date_from',30)]
]);
//チェックしてエラーが存在したら内側の処理を実行
if ($v->fails()) {
//全てのエラーメッセージ取得
var_dump($v->errors()->all());
}
$this->assertTrue(true);
}
}
オリジナルのValidator処理時の注意
自作のValidatorは、テスト時だけでなくコントローラー内のコード中でも用いることができますが、その際に注意したい点は、requestからValidateした際には自動で行われる、入力値やエラーの保存、リダイレクトの処理を自分で行わないといけないことです。
Validatorインスタンスからfailsメソッドでチェックを行いエラーとなった場合に、リダイレクトする場合は次のようにします。withErrorsでエラー、withInputで元の入力値も共にリダイレクト先に転送します。
if ($validator->fails()) {
return redirect('url')
->withErrors($validator)
->withInput();
}
エラー時にリダイレクトしないで、そのままビューを返すこともできます。
まずPOSTで受け取った値の保存をします。これはリクエストインスタンスからflushを呼ぶ事で可能です。
$request->flush();
その後バリデータインスタンスを作成して、failsメソッドを使ってバリデーションを実行するところは前例と同じです。
$v = Validator::make($params,['date'=>'required',...
$v->fails(){
...
次にエラー処理です。Laravelのデフォルトの挙動では、webルートに設定されているShareErrorsFromSessionミドルウェアが管理しています。これはセッションに保存されたエラーを、すべてのViewで利用可能な共通変数に格納する仕組みになっています。
この挙動に揃えたい場合は次のようにします。
ビューにおける共通変数については以前のこのブログにも触れましたが、Viewファサードからshareメソッドを使うことで共通変数を設定する事ができます。
それを使って、本来ミドルウェアが作業する部分を自分で記述します。ShareErrorsFromSessionは共通変数の$errorsにViewErrorBagのインスタンスをセットしますので、それに合わせて次のように記述します。
こうすることで$errorsがセットされ、@errorディレクティブが意図した挙動になります。
繰り返しになりますが、これは@errorディレクティブ用のものなので、エラーメッセージをview関数の第2引数経由で渡したりするなら無理に設定する必要はありません。
...
use Illuminate¥Support¥Facades¥View;
use Illuminate¥Support¥ViewErrorBag;
...
$veBag = new ViewErrorBag;
if ($validator->fails()) {
$veBag->put('default',$validator->getMessageBag());
View::share('errors', $veBag);
return view('form');
}
...
Validatorのインスタンスから取得できるMessageBagには複数のエラーが入っています。そのまとまりをBagという単位でまとめています。
フォームが複数ある時は、MessageBagがひとつだけの場合は、putにの第1引数に渡す値は「default」とします。複数ある場合にはそのキーを設定します。この時、.blade.phpの@errorディレクティブの記述も変更が必要です。
エラー検出の為には以上で問題ないのですが、2値を比較したエラーなのに一方(to)だけエラーとなります。これを修正したいのならルールのValidationインスタンスからaddFailureを呼び、もう一方(from)もエラーに加えます。
addFailureメソッドは第1引数にキー名称、第2引数にルール名称を入れます。ルール名称はlang¥en¥validation.phpにリストされているリストからエラーを探す際に使われますが、setCustomMessagesを使って動的に設定する事もできます。
エラーとしたいキー(要素)は複数でメッセージはひとつにしたかったので、何とかならないかと考えましたが、blade.phpで特定の値の場合は出力しないという原始的な対処方法しか見当たりませんでした。
blade.phpの@errorディレクティブはエラーメッセージの有無で判定をするようで、setCustomMessagesでカスタムメッセージをNULL文字('')にすると判定をすり抜けてしまいます。半角スペースならエラーと判定されます。
...
if ($this->limitDays < $dateFrom->diffInDays($dateTo)) {
$this->validator->setCustomMessages(['silent-error'=>'silent-error-plz-remove-manually']);
$this->validator->addFailure($this->date_from_key,'silent-error');
$fail('日付範囲は '.$this->limitDays.'日以内にしてください');
}...
アップロードファイルのValidation
アップロードファイルの中身をチェックしたい場合にもカスタムルールを用いることで対応が可能です。
カスタムルールを使わない場合は次のようにコントローラ内でアップロードされたファイルのチェックを行うと思います。このような処理をカスタムルールに反映させます。
controller.php
...
foreach($request->file('attachment_files') as $file) {
//正常にアップロードされたか
if($file->isValid()) {
//オリジナルのファイル名を取得
$file_name = $file->getClientOriginalName();
//データ取り出し
$raw_data = $file->get();
}
}
...
ルールの作成自体は前述の方法と同じです。ただ、ファイルの場合は$valueに入ってくる値がIlluminate¥Http¥UploadedFileクラスのインスタンスに変わります。さらに、multipleを付けた場合はUploadedFileクラスの配列となります。また、multiple指定時はHTML側のname属性に[]を付与して配列化すると思いますが、validationで指定する際には[]は不要です。
たとえばHTML側でファイルを選択できるフォームに「name=attachment_file[]」とした場合は、Validationでは「attachment_file=>[new FileValidationRule()]」とします。
filesend.html
...
<!-- LaravelでファイルをPOSTする際はenctype属性が必要です -->
<form action="./save" method="post" enctype="multipart/form-data">
<input type="file" name="attachment_file[]"/>
</form>
...
ルールは次のようなコードになります。ここではmultipleを指定して$valueが配列になっている前提で記述しています。
FileValidationRule.php
...
public function __invoke($attribute, $value, $fail){
for($i = 0; $i < count($value); $i++) {
// 正常にアップロードされたか
if($value[$i]->isValid()) {
// 送信時のファイル名を取得
$file_Name = $value[$i]->getClientOriginalName();
//ファイルの中身を取得
$raw_data = $value[$i]->get();
}
}
...
ルールを作成したらコントローラー内で適用します。ファイルが指定されていない場合は稼働しません。
controller.php
...
public function show(Request $request) {
$request->validate([
'attachment_files'=>[new FileValidationRule()],
]);
}
...
バリデーションエラーを日本語にしたい
デフォルトだとバリデーションエラーは英語で返ります。それを日本語にしたいと思います。
まず、localeの変更をします。config¥app.phpの中の「locale」行で行います。そのエントリーの手前に「timezone」もありますので必要に応じて変更してください。fallback_localeは設定したロケールが見つからない時の設定なので、こちらは変更しません。テストデータ生成にfakerを使っていて、それも日本語にしたいのなら「faker_locale」を変更します。
config¥app.php
...
# タイムゾーンも変える場合
'timezone' => 'Asia/Tokyo',
# enからjaに
'locale' => 'ja',
# そのまま
'fallback_locale' => 'en',
# faker
'faker_locale' => 'ja_JP',
ロケールを変更しただけでは、メッセージは変更されません。英語版(en)のバリデーションのメッセージはrecources¥en;¥validation.phpに存在します。このenフォルダをコピーしてjaフォルダを作ります。app.phpに設定したロケールと一致する名前のフォルダがある場合はメッセージはそのフォルダから読みだされます。ただしenフォルダをコピーしただけでは表記は英語のままなので、ここにリストされているメッセージを自力で、日本語訳する必要があります。それが手間なら、後で説明する方法で日本語訳済のファイルをコピーします。
ファイルをコピーしただけだと不自然な翻訳になる場合は、属性とルール名を指定してカスタムメッセージを生成することもできます。Laravelのサンプルの記述をそのまま借用すると次のようになります。
validation.php
...
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
さらに、エラーメッセージをの設定php内では「:attribute」と書かれている場所には、htmlのname属性で設定した名前が入るようになっています。 しかしname属性に日本語の値を入れるとブラウザにより挙動が不安定になります。また、常にname属性値と同じわけにもいかないかもしれません。そのような際に用いるのがvalidation.phpの最後にある'attributes'の項目です。例えば「namepost」を「名前」に、「emailpost」を「メールアドレス」にするには次のようにします。
validation.php
...
'attributes' => ['namepost'=>'名前','emailpost'=>'メールアドレス'],
...
いちから翻訳するのは大変なので、翻訳済みのファイルがないか探してみたところ、Zenn:「Laravel8の認証(Jetstream)を日本語化しよう」によれば、「laravel-lang/lang」という翻訳パッケージがあるそうです。
また、laravel-lang/publisherを利用すればコマンドで日本語環境をインストールできますのでそれを利用します。composerで--devオプションを付けてインストールします。またファイルだけが必要なので既にダウンロードしたものがあればそのコピーが使えます
PS> composer require laravel-lang/publisher laravel-lang/lang --dev ... PS> php artisan vendor:publish --provider="LaravelLang\Publisher\ServiceProvider" ... PS> php artisan lang:add ja
すべてのコマンドが終わると、「lang」フォルダに、「ja」というフォルダが作成されていると思います。そこに生成されたファイルに対して先のattributesの項目等を編集して、自然な翻訳にカスタマイズしていきます。
ロケールを削除したい場合はaddの部分をrmにします。他操作は公式ページを参考にしてください。
参考にさせていただきましたサイトの皆様、ありがとうございました。