PHP
laravel
Validation
フレームワーク
laravel5.6

Laravel の Validation を正しく拡張する

どうもはじめまして。
moobay9 と申します。
Laravel は初心者ですが、PHP はまあまあ長く触っております。

今回は Laravel のバリデーションを拡張するときに Attributes が効かない問題に遭遇したので、解決方法を残しておきます。
「PHPフレームワーク Laravel入門」という書籍を参考に、なんとかフォームが作れるようにはなってきたところなんですが、今回の問題に遭遇しました。

今回は Laravel の 5.6 を利用しています。

いきなり解決方法を知りたい人は一番下まで飛んでください。

Laravel のバリデーション拡張とは

Laravel のバリデーションはかなり強力で必要なものはデフォルトでほとんど揃ってしまっているのですが、日本人である限りやはり不足があります。
たとえばこんなの。

    /**
     * validateKatakana カタカナのバリデーション(ブランクを許容)
     *
     * @param string $value
     * @access public
     * @return bool
     */
    public function validateKatakana($attribute, $value, $parameters)
    {
        return (bool) preg_match('/^[ァ-ヾ  〜ー−]+$/u', $value);
    }

カタカナであるかの判定ですが、フリガナをチェックしたい場合はこういうバリデーションも必要なわけです。

これを設定するためにはオリジナルの拡張が必要でして、上記のLaravel入門では P148 付近からの記載になります。

App\Http\Validators\ExtensionValidator.php
namespace App\Http\Validators;

use Illuminate\Validation\Validator;

class ExtensionValidator extends Validator
{

    /**
     * validateKatakana カタカナのバリデーション(ブランクを許容)
     *
     * @param string $value
     * @access public
     * @return bool
     */
    public function validateKatakana($attribute, $value, $parameters)
    {
        return (bool) preg_match('/^[ァ-ヾ  〜ー−]+$/u', $value);
    }
}

こんな具合になりますね。メソッドは必ず public function validateHoge のような形にする必要があるようです。
親になるクラスは use で引っ張っている Illuminate\Validation\Validator です。

このままでは拡張クラスは有効になっていませんので、これをサービスプロバイダーで有効にしてやる必要があります。

App\Providers\ValidatorServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Http\Validators\ExtensionValidator;

class ValidatorServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        $this->app['validator']->resolver(function($translator, $data, $rules, $messages) {
            return new ExtensionValidator($translator, $data, $rules, $messages);
        });
    }

    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

Laravel入門では P150 に記載がある拡張方法ですね。
このように boot() の中で、resolver() に引数でクロージャを渡し、クロージャ内で拡張バリデーションをインスタンス化しているのが見て取れます。

別の書き方として、

\Validator::resolver(function($translator, $data, $rules, $messages) {
    return new ExtensionValidator($translator, $data, $rules, $messages);
});

という書き方もできます。同じ効果です。

さて、ここまできて Attributes が効かない、とは何か。

Attributes とは?

エラーメッセージを表示するときに、ほったらかしにしときますと「email は必須項目です」という表示になります。
(言語設定を日本語にし、App\Resources\lang\ja\Validation.php を適切に設定していたら)

しかしながら、ユーザーのためには「メールアドレス は必須項目です」という表示にしたいところ。
これを書き換える部分が Attribute な訳ですが、主に二箇所で指定できます。

フレームワーク全体に効かせたい場合

App\Resources\lang\ja\Validation.php
return [
    'attributes' => [
        'email'          => 'メールアドレス',
        'password'       => 'パスワード',
        'lastname'       => '姓',
        'firstname'      => '名',
        'lastname_kana'  => 'セイ',
        'firstname_kana' => 'メイ',
        'gender'         => '性別',
    ],
];

言語設定ファイルに記載します。
こうすることで、フレームワーク全体へ設定が効きます。

フォームごとに設定したい場合

Laravel のフォームは通常、App 下の Requests 内に FormRequest クラスの拡張を置くかと思います。

php artisan make:request HogeRequest

と artisan コマンドを実行すると、ファイルが作成されますね。
色々書き換える必要がありますが、適切に書くとこんな具合。

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class HogeRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'email'           => 'required|max:255|email|unique:users,email',
            'password'        => 'required|max:255|min:8',
            'is_mailmagazine' => 'required|boolean',
            'callback'        => 'url',
        ];

    }

    public function attributes()
    {
        return [
            'email'           => 'メールアドレス',
            'password'        => 'パスワード',
            'is_mailmagazine' => 'メールマガジン',
            'callback'        => 'URL',
        ];
    }


}

ここで出て来ましたね。attributes() の中で設定することで、対象としているフォームにのみ要素名を置き換えることが可能になります。
で、Attirbutes が効かないのはここです。

Attributes が効かない

FormRequest クラスの拡張で attributes が書籍に書いてある通りにやっているのにさっぱり効かない。
Resource下の言語ファイルでグローバル設定にしてるほうは効くのに、フォームごとにやるとダメなのです。

バグなのか? と何度も疑いましたが、そんな話は一切出てこない。

Google 先生も答えてくれない、となったらもうコアを読むしかありません。

意地で処理を追いかける

ではどこから手をつけるか、となったら先ずは FormRequest からチェックします。

Illuminate\Foundation\Http\FormRequest.php
namespace Illuminate\Foundation\Http;

use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidatesWhenResolvedTrait;
use Illuminate\Contracts\Validation\ValidatesWhenResolved;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;

class FormRequest extends Request implements ValidatesWhenResolved
{
    use ValidatesWhenResolvedTrait;

    // 略

    protected function getValidatorInstance()
    {
        $factory = $this->container->make(ValidationFactory::class);

        if (method_exists($this, 'validator')) {
            $validator = $this->container->call([$this, 'validator'], compact('factory'));
        } else {
            $validator = $this->createDefaultValidator($factory);
        }

        if (method_exists($this, 'withValidator')) {
            $this->withValidator($validator);
        }

        return $validator;
    }

    protected function createDefaultValidator(ValidationFactory $factory)
    {
        return $factory->make(
            $this->validationData(), $this->container->call([$this, 'rules']),
            $this->messages(), $this->attributes()
        );
    }
    // 略
}

getValidatorInstance() で createDefaultValidator() を呼んでいますが、createDefaultValidator() ではとりあえずインスタンスを作っているだけのようです。
ですが見てわかる通り、最後の引数で attributes() を処理するようになっています。

で、ここで注目すべきは Factory で、Factoryの実体である Illuminate\Contracts\Validation\Factory を追いかけます。

Illuminate\Contracts\Validation\Factory.php
namespace Illuminate\Validation;

use Closure;
use Illuminate\Support\Str;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Translation\Translator;
use Illuminate\Contracts\Validation\Factory as FactoryContract;

class Factory implements FactoryContract
{
    public function make(array $data, array $rules, array $messages = [], array $customAttributes = [])
    {
        $validator = $this->resolve(
            $data, $rules, $messages, $customAttributes
        );

        if (! is_null($this->verifier)) {
            $validator->setPresenceVerifier($this->verifier);
        }

        if (! is_null($this->container)) {
            $validator->setContainer($this->container);
        }

        $this->addExtensions($validator);

        return $validator;
    }

    protected function resolve(array $data, array $rules, array $messages, array $customAttributes)
    {
        if (is_null($this->resolver)) {
            return new Validator($this->translator, $data, $rules, $messages, $customAttributes);
        }

        return call_user_func($this->resolver, $this->translator, $data, $rules, $messages, $customAttributes);
    }

    // 略
}

はい、怪しい箇所がでてきました。resolve() です。先ほどの FormRequest で getValidatorInstance() の中で make() を呼んでいる部分は Factory の make() メソッドですね。
この中で resolve が呼ばれています。ここでは引数が最大4個渡せるようになっており、最後は attribute がらみのようです。

...ん?

return call_user_func($this->resolver, $this->translator, $data, $rules, $messages, $customAttributes);

$this->resolver ってなんじゃい。

    /**
     * Set the Validator instance resolver.
     *
     * @param  \Closure  $resolver
     * @return void
     */
    public function resolver(Closure $resolver)
    {
        $this->resolver = $resolver;
    }

いました、同じ Factory の中に。
そして引数はまさにクロージャを渡している。

サービスプロバイダーで設定しているときに呼ばれている resolver() もこいつとみて間違いないでしょう。
となると、

return call_user_func($this->resolver, $this->translator, $data, $rules, $messages, $customAttributes);

call_user_funcを読めばわかりますが、第一引数が関数、第二引数以降がコールバック関数の引数になるわけですが、どうみても5個ある。そして最後は Attribute ですネ☆。

解決

App\Providers\ValidatorServiceProvider.php
{
    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        $this->app['validator']->resolver(function($translator, $data, $rules, $messages, $attributes) {
            return new ExtensionValidator($translator, $data, $rules, $messages, $attributes);
        });
    }
}

ここまで来ると、もう正解はわかりましたね。
サービスプロバイダーで登録するときに引数を増やしてやるだけです。

というかなんでこんなにも誤った書き方が広がっているのか、意味がわかりません...