|||||||||||||||||||||

なんぶ電子

- 更新: 

LaravelのValidationのカスタマイズ

Laravel Custom Rule

Laravelのバリデーションは非常に便利ですが、デフォルトで用意されているルールではbeforeなどの一部を除き、ふたつ以上の入力値を比較してエラーを発生させことができません。

そこで、独自のルールを作成し、ふたつの入力の比較結果によるエラーチェックをバリデーションに組み込んでみたいと思います。

サンプルとして用意したシナリオは、「date_from(開始日)」と「date_to(終了日)」のふたつの日付を比較してその期間が30日を超えた場合に「期間エラー」をとするものです。

カスタムルールの作成

まず、カスタムルールを作成します。ここでは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無いでValidator自身のインスタンスを受け取っています。Validator自身のインスタンス内を参照することで、他のデータだけでなく、そのチェックの結果を参照できるようになります。

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.'日以内にしてください');
    }
  }
}

また、テストでは次のようなコードを作成しました。

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'),
    ];
    
    $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での処理時

上記のテストではRequestを使わずにそれらでデータを受け取ったと仮定してパラメータを自作し、それに適用させるためにValidatorも自作しました。

自作のValidatorは実際のコードの中でも用いることができますが、その際に注意したい点は、requestからValidateした際には自動で行われる、入力値やエラーの保存、リダイレクトも自分で行わないといけないことです。

それらの処理を手動でやる場合はValidatorインスタンスから、failsメソッドでチェックを行いエラーとなった場合の処理を記述する関数内で、エラーと入力値と共にリダイレクトします。

if ($validator->fails()) {
  return redirect('url')
    ->withErrors($validator)
    ->withInput();
}

エラー時にリダイレクトしないでそのままビューを返したい場合があるかもしれません。そのような場合は処理を個別に行います。

入力時のチェックは自作のValidatorのインスタンスからfailsメソッドを呼ぶ前述の方法を使い、関数の内部をリダイレクトではなくエラー処理をした上でビューを返すようにします。(requestからvalidate

入力値の保存はリクエストのインスタンスからflushを呼ぶ事で可能です。

$request->flush();

次にエラー処理です。エラーはLaravelのデフォルトの挙動では、webルートに設定されているShareErrorsFromSessionミドルウェアが管理しています。これはセッションに保存されたエラーを、すべてのViewで利用可能な共通変数に格納する仕組みになっています。

ビューにおける共通変数については以前のこのブログにも触れましたが、Viewファサードからshareメソッドを使うことで共通変数を設定する事ができます。

それを使って、本来ミドルウェアが作業する部分を自分で記述します。ShareErrorsFromSessionは共通変数の$errorsにViewErrorBagのインスタンスをセットしますので、それに合わせて次のように記述します。

こうすることで$errorsがセットされ、@errorディレクティブが意図した挙動になります。

...
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.'日以内にしてください');
}...

バリデーションエラーを日本語にしたい

デフォルトだとバリデーションエラーは英語で返ります。それを日本語にしたいと思います。

まず、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にします。他操作は公式ページを参考にしてください。


参考にさせていただきましたサイトの皆様、ありがとうございました。

筆者紹介


自分の写真
がーふぁ、とか、ふぃんてっく、とか世の中すっかりハイテクになってしまいました。プログラムのコーディングに触れることもある筆者ですが、自分の作業は硯と筆で文字をかいているみたいな古臭いものだと思っています。 今やこんな風にブログを書くことすらAIにとって代わられそうなほど技術は進んでいます。 生活やビジネスでPCを活用しようとするとき、そんな第一線の技術と比べてしまうとやる気が失せてしまいがちですが、おいしいお惣菜をネットで注文できる時代でも、手作りの味はすたれていません。 提示されたもの(アプリ)に自分を合わせるのでなく、自分の活動にあったアプリを作る。それがPC活用の基本なんじゃなかと思います。 そんな意見に同調していただける方向けにLinuxのDebianOSをはじめとした基本無料のアプリの使い方を紹介できたらなと考えています。

広告