Laravelのバリデーションが面倒なのでオレオレ実装したった🤘

はじめに

こんにちは、次世代デジタル基盤開発事業部の安彦です。
これまでPHP未経験だったのですが、6月から業務で使い始めました。
Laravelの所感としては、しっかりしたMVCモデルで、書き慣れたらすごい速さで実装が進むなぁと感じました。

しかし、気になることが1点...
バリデーションが面倒すぎる!!
これを克服する簡単な実装ができたので共有します。

作りたい画面

氏名と各科目の点数を入力するフォームを用意して、
期待する値が入力された場合には、「リクエスト成功」と表示し、
想定外の値が入力された場合には、「各入力欄にエラー」を表示したいです。

よくある基本的な入力フォームですが、これをLaravelで作ると少し面倒なコーディングが必要でした。

送信成功画面
送信失敗画面

Laravel での一般的な実装方法

view

まず、view用に resources/views/exam_score.blade.php を作成します。
今回はDBの話をしないので、Eloquentは登場しません。

成功時も同じviewを返すので、inputタグのvalue属性に
コントローラの戻り値($inputs)と バリデーションエラーの戻り値(old())の両方を使用します。

<form method="post">
    @csrf
    <div>
        <label>氏名</label>
        <input name="full_name" type="text" value="{{ $inputs['full_name'] ?? old('full_name')}}" />
        <div class="error">
            @error('full_name') {{$message}} @enderror
        </div>
    </div>
    <table>
        <tr>
            <th>科目</th>
            <th>点数</th>
        </tr>
        @foreach(['国語', '数学', '英語'] as $i => $sub)
        <tr>
            <td>{{$sub}}</td>
            <td>
                <input name="scores[]" value="{{ $inputs['scores'][$i] ?? old("scores.{$i}") }}" />
            </td>
        </tr>
        <tr>
            <td></td>
            <td class="error">
                @error("scores.{$i}") {{$message}} @enderror
            </td>
        </tr>
        @endforeach
    </table>
    <button type="submit">送信</button>
</form>
@isset($requestClass)
<div class="result">
    <span class="success">リクエスト成功<br></span>
</div>
@endisset

ルーティング

続いて routes/web.php にルーティングを記載

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ExamController;

Route::get('/', [ExamController::class, 'showRegister']);
Route::post('/', [ExamController::class, 'insert']);

コントローラ

続いて、コントローラ
今回はフォームリクエストを作成してバリデーションを実装するので、
自作のリクエストを引数にとり、viewを返すのみです。

namespace App\Http\Controllers;

use App\Http\Requests\PostRequest;
use Illuminate\Routing\Controller as BaseController;

class ExamController extends BaseController
{
    public function showRegister()
    {
        return view('exam_score');
    }

    public function insert(PostRequest $req)
    {
        return view('exam_score', ['inputs'=>$req->all()]);
    }
}

リクエスト

そして、本題のバリデーションを行うリクエストです。
Laravelドキュメントのバリデーションを元に実装します。

readouble.com

rulesメソッドにバリデーションルールを記載します。
'パラメータ名' => ['ルール名', ... ] の形なので、
Illuminate\Http\Requestvalidate メソッドを使う場合と一緒です。

今回は langディレクトリvalidation.phpを使わずにエラーメッセージを調整します。
messagesメソッドで、'パラメータ名.ルール名' => '表示する文字列' の配列を返します。

このrulesとmessagesに同じ単語を何度も入力する作業が大変面倒に感じました。

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'full_name' => ['required', 'min:3', 'max:10'],
            'scores'    => ['required', 'array'],
            'scores.*'  => ['required', 'integer', 'min:0', 'max:100'],
        ];
    }

    public function messages(): array
    {
        return [
            'full_name.required' => '氏名を入力してください',
            'full_name.min'      => '氏名は3文字以上で入力してください',
            'full_name.max'      => '氏名は10文字以内で入力してください',
            'scores.required'    => 'スコア一覧を入力してください',
            'scores.array'       => 'スコア一覧を入力に誤りがあります',
            'scores.*.required'   => 'スコアを入力してください',
            'scores.*.integer'    => 'スコアは整数を入力してください',
            'scores.*.min'        => 'スコアは0以上の値を入力してください',
            'scores.*.max'        => 'スコアは100以下の値を入力してください',
        ];
    }
}

オレオレ実装したリクエストクラス

リクエストクラス

同じ単語を二度と打たなくて良いようにした考えた結果、
こんなリクエストクラスになりました。

実装するのはaddValidateメソッドのみ!
InputParamクラスの配列を引数にして、
親クラスのmergeInputParamsメソッドを呼び出します。

InputParamcreate('パラメータ名', '表示用パラメータ名') でインスタンス化して、
required()などLaravelのルール名に似た名前のメソッドをメソッドチェーンする形です。

ルール指定をメソッド化したことで、
文字列よりも補完が効きやすいのがかなりメリットかなと思います。

namespace App\Http\Requests;

use App\Http\Requests\EasyValidator\InputParam;
use App\Http\Requests\EasyValidator\Request;

class PostEasyRequest extends Request
{
    protected function addValidate(): void
    {
        $this->mergeInputParams([
            InputParam::create('full_name', '氏名')->required()->stringMin(3)->stringMax(10),
            InputParam::create('full_name', '氏名')->required()->stringMin(3)->stringMax(10),
            InputParam::create('scores', 'スコア一覧')->required()->array(),
            InputParam::create('scores.*', 'スコア')->required()->integer()->intMin(0)->intMax(100),
        ]);
    }
}

コントローラのメソッドの引数をPostEasyRequestに置き換えるだけで、使用できます。

class ExamController extends BaseController
{
    public function insert(PostEasyRequest $req)
    {
        return view('exam_score', ['inputs'=>$req->all()]);
    }
}

リクエストの親クラス

続いて、細かい実装を見ていきます。
まず、先のリクエストが継承していた親クラスです。

「Laravel での一般的な実装方法」でも継承していた、FormRequestを継承しています。
子クラスはaddValidateを実装する必要があります。

addValidatevalidator中で呼びれ、
validatorはLaravelのFormRequestで呼び出されます。

namespace App\Http\Requests\EasyValidator;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;

class Request extends FormRequest
{
    // これを実装する
    protected function addValidate(): void
    {
        $this->mergeInputParams([]);
    }

    public function validator(ValidationFactory $factory)
    {
        $this->addValidate();
        return $this->createDefaultValidator($factory);
    }
    // 下に続く

次に、addValidate で呼び出していたmergeInputParams です。
引数の[]InputParamのルールとメッセージを取得して、
自身のaddRulesプロパティに追加しています。

最後に、rulesmessagesメソッドが各プロパティを返しています。
「Laravel での一般的な実装方法」でそれぞれ実装していたメソッドを、
自身のプロパティを返す形に変更している仕組みです。

    protected array $addRules    = [];
    protected array $addMessages = [];

    /**
     * @param []InputParam $inputs
     */
    protected function mergeInputParams(array $inputs): void
    {
        foreach ($inputs as $input) {
            $this->addRules    = array_merge($this->addRules, $input->getRules());
            $this->addMessages = array_merge($this->addMessages, $input->getMessages());
        }
    }

    public function rules(): array
    {
        return $this->addRules;
    }

    public function messages(): array
    {
        return $this->addMessages;
    }

    // クラス終わり
}

各パラメータの情報を管理するクラス

次に「氏名」などの各パラメータの情報をもつクラスです。

プロパティのrulesは、Laravelのバリデーションルール名の配列で、
messagesは、'title.required' => 'A title is required' のように、「Laravel での一般的な実装方法」でのmessagesメソッドで返す形のマップを保持します。
コンストラクタ側に記載しているparamNameとdisplayNameはPOSTパラメータ名とメッセージでの表示名です。

メソッドチェーンでルールを追加していくので、createメソッドのみpublicにして、
全ルールで共通のpramNameとdisplayNameだけ初期化します。

namespace App\Http\Requests\EasyValidator;

class InputParam
{
    /** @var string[] */
    private array $rules;

    /** @var array<string,string> */
    private array $messages;

    private function __construct(
        private string $paramName = '',
        private string $displayName = '',
    ) {
        $this->rules = [];
        $this->messages = [];
    }

    public static function create(
        string $paramName,
        string $displayName,
    ): InputParam {
        return new InputParam($paramName, $displayName);
    }

// 下に続く

次に RequestクラスのmergeInputParamsメソッドで呼び出されるメソッドです。
それぞれ、ルールとメッセージのマップを返します。

    public function getRules()
    {
        // ex) ['full_name' => ['required', 'max:10']]
        return [$this->paramName => $this->rules];
    }

    public function getMessages()
    {
        // ex) ['full_name.required' => '氏名必須hogehoge', 'full_name.max' => '氏名最大hogehoge']
        return $this->messages;
    }
// 下に続く

次に、Laravel準拠のルールを設定して、自身を返すsetRuleメソッドです。

    public function setRule(string $rule, string $messageKey, string $message)
    {
        $this->rules[] = $rule;
        $this->messages[$messageKey] = $message;
        return $this;
    }
// 下に続く

最後に、各ルールを設定するメソッドを用意します。
requiredなどLaravelで使えるバリデーション名と、表示したいメッセージを設定しています。
min/maxはLaravelだと数値と文字列の区別がないため、
stringMaxintMaxなどメソッドを分けることで、メッセージをカスタマイズしています。
nullablebooleanなどLaravelのバリデーションも使いたいときに実装する作りになっています。

    /** ルール required を設定 */
    public function required()
    {
        $rulePrefix = 'required';
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}を入力してください";
        return $this->setRule($rulePrefix, $messageKey, $message);
    }

    /** ルール max を設定 */
    public function stringMax(int $value=100)
    {
        $rulePrefix = 'max';
        $rule = "{$rulePrefix}:{$value}";
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}は{$value}文字以内で入力してください";
        return $this->setRule($rule, $messageKey, $message);
    }

    /** ルール min を設定 */
    public function stringMin(int $value=10)
    {
        $rulePrefix = 'min';
        $rule = "{$rulePrefix}:{$value}";
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}は{$value}文字以上で入力してください";
        return $this->setRule($rule, $messageKey, $message);
    }

    /** ルール max を設定 */
    public function intMax(int $value=100)
    {
        $rulePrefix = 'max';
        $rule = "{$rulePrefix}:{$value}";
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}は{$value}以下の値を入力してください";
        return $this->setRule($rule, $messageKey, $message);
    }

    public function integer()
    {
        $rulePrefix = 'integer';
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}は整数を入力してください";
        return $this->setRule($rulePrefix, $messageKey, $message);
    }

    public function array()
    {
        $rulePrefix = 'array';
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}の入力に誤りがあります";
        return $this->setRule($rulePrefix, $messageKey, $message);
    }

    /** ルール min を設定 */
    public function intMin(int $value=10)
    {
        $rulePrefix = 'min';
        $rule = "{$rulePrefix}:{$value}";
        $messageKey = "{$this->paramName}.{$rulePrefix}";
        $message = "{$this->displayName}は{$value}以上の値を入力してください";
        return $this->setRule($rule, $messageKey, $message);
    }

    // TODO: required, stringMax 同様に Laravelで使用可能なバリデーションを実装
} // クラス終わり

実装は以上です。
前記のとおり、メソッドチェーンでルールとメッセージを設定できます。
また、SetRuleメソッドを直接使うことで、各リクエスト固有のルールも設定可能です。

namespace App\Http\Requests;

use App\Http\Requests\EasyValidator\InputParam;
use App\Http\Requests\EasyValidator\Request;

class PostEasyRequest extends Request
{
    protected function addValidate(): void
    {
        $this->mergeInputParams([
            InputParam::create('full_name', '氏名')->required()->stringMin(3)->stringMax(10),
            InputParam::create('full_name', '氏名')->required()->stringMin(3)->stringMax(10),
            InputParam::create('scores', 'スコア一覧')->required()->array(),
            InputParam::create('scores.*', 'スコア')->required()->integer()->intMin(0)->intMax(100),
            // setRuleで独自のルールも設定可能
            InputParam::create('email', 'メールアドレス')->required()->stringMax(100)
                ->setRule('unique:App\Infrastructure\Eloquents\User,email', 'unique', 'そのメールアドレスは使用されています'),
        ]);
    }
}

おわりに

最後まで読んで頂きありがとうございます。

今回は、Laravelの理解が浅い筆者が、
Laravelのバリデーションをオレオレ実装で使いやすく?してみました。
Requestクラスでvalidatorメソッドを実装してLaravelのFormRequestに呼ばれるようにする箇所は、先輩エンジニアの力を借りました。)

メッセージのカスタマイズだけであれば多言語化ファイルを使った方が、
Laravelに素直なコードになった気がしています。

ただ、Laravelにはバリデーションのように決まった文字列の配列を渡す機会が多いので、
その場面ではメソッドチェーン化するのが有効かなと思いました。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp