Laravel 8 カスタムルール用のインターフェース DataAwareRule と ValidatorAwareRule で戯れてみる

4 min read読了の目安(約4000字

はじめに

Laravel 8.42、8.43で追加されたカスタムルール用のインターフェース DataAwareRule と ValidatorAwareRule で戯れてみました。

DataAwareRule は、無くてもどうにかなったり、ValidatorAwareRule の方は、代わりにAfterフックとか使えば何とかなったりもしますが、これらを使うとより綺麗に決めることができます。

なお、執筆時点では、どちらもドキュメントに記載はありません。たぶん、今後も無いのではないかと思われます。

参考:GitHub [8.x] Add ValidatorAwareRule interface

シナリオ

少し前に、某ワクチン予約システムで、存在しない市区町村コードでも予約できてしまうと、話題になりました。そこで今回は、都道府県名と市区町村名(コードではなく)をテキストに入力してもらい、市区町村名が実在するかをチェックするというカスタムルールを上記の2つのインターフェースを使いつつ、実現して行きたいと思います。

市区町村チェックは、「埼玉県」で「千代田区」とかも、もちろんダメとします。

早速作成

市区町村名を管理するテーブルを作る為、次のコマンドを打ち、モデルとマイグレーションファイルを作成します。
ついでにカスタムルールを作成するコマンドも打ちます。

php artisan make:model City -m
php artisan make:rule CityCheck

作成されたマイグレーションは、以下のようにします。

    public function up()
    {
        Schema::create('cities', function (Blueprint $table) {
            $table->id();
            $table->string('pref');
            $table->string('name');
            $table->timestamps();
        });
    }

DatabaseSeeder は、以下とします。(取りあえずここでは、3都道府県と5市区町村のみ)

    public function run()
    {
        \DB::table('cities')->insert([
            ['pref' => '東京都', 'name' => '千代田区'],
            ['pref' => '東京都', 'name' => '中央区'],
            ['pref' => '埼玉県', 'name' => 'さいたま市'],
            ['pref' => '埼玉県', 'name' => '川越市'],
            ['pref' => '千葉県', 'name' => '市川市'],
        ]);
    }

web.php は、以下とします。

<?php

use App\Rules\CityCheck;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::post('/', function (Request $request) {
    $validated = $request->validate([
        'pref' => ['required', 'string', 'in:東京都,埼玉県,千葉県'],
        'city' => ['required', 'string', 'max:20', 'bail', new CityCheck()],
    ]);

    dd($validated);
});

welcome.blade.php は、以下の感じ。

    @if($errors->any())
        <ul style="color: red">
        @foreach($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
        </ul>
    @endif

    <form method="post">
        @csrf

        都道府県:<input name="pref" value="{{ old('pref') }}">
        市区町村:<input name="city" value="{{ old('city') }}">

        <input type="submit" value="送信する">
    </form>

問題の CityCheck は、以下の感じ。

<?php

namespace App\Rules;

use App\Models\City;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;

class CityCheck implements Rule, DataAwareRule, ValidatorAwareRule
{
    private $data;

    private $validator;

    public function setData($data)
    {
        $this->data = $data;

        return $this;
    }

    public function setValidator($validator)
    {
        $this->validator = $validator;

        return $this;
    }

    public function passes($attribute, $value)
    {
        if ($this->validator->errors()->has('pref')) {
            return true;
        }

        $pref = data_get($this->data, 'pref');

        return City::where('pref', $pref)->where('name', $value)->exists();
    }

    public function message()
    {
        return 'その市区町村は、その都道府県には存在しません。';
    }
}

今回の validation では、
(1) 都道府県で既にエラーが出ている時は、DBを使った市区町村存在チェックは行わない、とし、
(2) また、市区町村についても市区町村の他でエラーが出ている時(市区町村名が100文字の時とか)は、同じくDBチェックは行わないという仕様にします。

(1) を実現する為に、ValidatorAwareRule というインターフェースを実装し、setValidator() メソッドを定義しています。これで、フレームワーク側が自動でこれを呼び出してくれ、validator オブジェクトを取得できますので、passes() メソッドの最初で、prefでエラーが発生しているかチェックして、発生していれば、true を返して終わりにしています。

もしエラーが発生していない時は、都道府県の値を取得する為に、DataAwareRule インターフェースを実装し、setData() メソッドを定義しています。
これでフレームワーク側がこのメソッドを呼び出してくれ、validation の対象となっている全データを取得することができますので、これに対し data_get() ヘルパー関数を使って、都道府県名だけを取得しています。

最後に、DBに存在確認して、その結果を return しています。

(2)についても(1)と同様の方法で実現できますが、今回は元のルール側に 'bail' を付けることで実現しています。これを付けることで、既に市区町村でエラーが出ていれば、DBチェックを走らせないようにする事ができます。(ちなみに、値が空の時(null)は、'bail'が無くても走りませんね)

感想

どちらも地味に便利そうです。

おかしな箇所等ありましたら、コメント下さい。