Laravel10でカスタムバリデーションメッセージを複数設定する方法

## はじめに

パスワードのバリデーションを例に挙げます。

`'password' => 'required|min:8|max:128|alpha_dash',` のようにすれば、
とりあえず最低限のバリデーションは実装できます。
ですが、`alpha_dash` は全角文字が通るという、ちょっと嬉しくない仕様になっています。

https://yutaro-blog.net/2021/05/05/laravel-validation-alpha/

まあパスワードに全角文字を使う酔狂なユーザーはいないと思うので、無視してもいい気もしますが、
うちは結構、そのあたりうるさいので、より厳格なパスワードバリデーションを実装します。

ただ、「パスワードが正しくありません」と出すだけでは不親切ですよね？
パスワードの正しくない箇所に応じて、スクショのように入力内容に応じたエラーメッセージを出すようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/6df227dd-c77d-9b42-4b51-1be5c5c366f9.png)

## パスワードのルール

* 8文字以上、128文字以下であること
* 少なくとも1文字以上、半角のアルファベット小文字を含むこと
* 少なくとも1文字以上、半角のアルファベット大文字を含むこと
* 少なくとも1文字以上、半角数字を含むこと
* 少なくとも1文字以上、右記の記号のいずれかを含むこと ( `!?@#$%^&*()\-_=+{};:,<.>~` )

## 実装

こんな風にする

```
cd /path/to/project_dir/
php artisan make:rule StrongPassword
php artisan make:request AuthRequest
php artisan make:controller AuthController --model=User
touch resources/views/auth/login.blade.php
```

```php:app\Rules\StrongPassword.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class StrongPassword implements ValidationRule
{
    /**
     * バリデーションルールの実行
     * NOTE: Laravel 9系までと10系ではバリデーションの書き方が大きく異なっているので
     * 必ずリファレンスを確認すること
     * 
     * @see https://readouble.com/laravel/10.x/ja/validation.html#custom-validation-rules
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $regexLowercase = '/[a-z]/';
        $regexUppercase = '/[A-Z]/';
        $regexNumber = '/[0-9]/';

        $specialPattern = '!?@#$%^&*()\-_=+{};:,<.>~';
        $regexSpecial = "/[{$specialPattern}]/";

        $hasLowerCase = (preg_match_all($regexLowercase, $value) > 0);
        $hasUpperCase = (preg_match_all($regexUppercase, $value) > 0);
        $hasNumber = (preg_match_all($regexNumber, $value) > 0);
        $hasSpecial = (preg_match_all($regexSpecial, $value) > 0);

        if(! $hasLowerCase) $fail(':attributeは少なくとも1文字以上、半角英小文字を含む必要があります');
        if(! $hasUpperCase) $fail(':attributeは少なくとも1文字以上、半角英大文字を含む必要があります');
        if(! $hasNumber) $fail(':attributeは少なくとも1文字以上、半角数字を含む必要があります');
        if(! $hasSpecial) $fail(":attributeは少なくとも1文字以上、右記の記号のいずれかを含む必要があります {$specialPattern}");
    }
}
```

```php:app\Http\Requests\AuthRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

use App\Rules\StrongPassword;

class AuthRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required','email','max:255'],
            'password' => ['required','min:8','max:128', new StrongPassword()],
        ];
    }
}
```

```php:app\Http\Controllers\AuthController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

use App\Models\User;

use App\Http\Requests\AuthRequest;

class AuthController extends Controller
{
    use AuthorizesRequests, ValidatesRequests;

    public function login()
    {
        $viewName = Route::currentRouteName();
        return view($viewName);
    }

    public function loginAuth(AuthRequest $request)
    {
        $email = $request['email'];
        $password = $request['password'];

        $is_auth = false;
        $user = User::where('email', $email)->first();
        if ($user != null) {
            if (password_verify($password, $user->password)) {
                $is_auth = true;  // パスワード認証成功
            }
        }

        if ($is_auth) {
            session()->forget('user');
            session()->push('user', $user);

            $routeName = 'admin.top';
            $message = trans('messages.login');
            return redirect()->route($routeName)->with('success', $message);
        }
        else {
            $res = back()->withErrors([
                'password' => trans('auth.invalid')
            ]);

            return $res;
        }
    }
}
```

```php:resources/views/auth/login.blade.php
@if(session('success'))
      <div id="alert-message" class="alert alert-success">{{ session('success') }}</div>
@endif
@if ($errors->any())
      <div id="alert-messages" class="alert alert-danger">
        <ul>
    @foreach ($errors->all() as $error)
          <li>{{ $error }}</li>
    @endforeach
        </ul>
      </div>
@endif
      {{ Form::open(['url' => 'login', 'method' => 'post']) }}
          {{ Form::email('email', '') }}
          {{ Form::password('password') }}
          <button type="submit">{{ __('action.login') }}</button>
      {{ Form::close() }}
@endsection
```

※ViewにはLaravel Collectiveを使用しています

## ポイント

バリデーションメッセージを複数出すには、 `$fail` コールバックを使います。

`$fail(':attributeは少なくとも1文字以上、半角英小文字を含む必要があります');` みたいな感じのやつを書けば、
エラーメッセージを複数ストックできますので、Viewで `@foreach ($errors->all() as $error)` のループで回すことで全てのエラーを表示可能です。

## 追記

パスワードはパスワードルールオブジェクトというものを使えば
自前で実装する必要がなかったです。
すいません知りませんでした (*ﾉωﾉ)

```php:app\Http\Requests\AuthRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

use Illuminate\Validation\Rules\Password;

class AuthRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        // パスワードルールオブジェクトについて
        // https://readouble.com/laravel/10.x/ja/validation.html#validating-passwords
        // https://laravel.com/api/10.x/Illuminate/Validation/Rules/Password.html
        return [
            'email' => ['required','email','max:255'],
            'password' => [
                'required',
                'max:255',
                Password::min(8)
                ->letters()   // 最低1文字の文字が必要
                ->mixedCase() // 最低大文字小文字が1文字ずつ必要
                ->numbers()   // 最低1文字の数字が必要
                ->symbols()   // 最低1文字の記号が必要 右記の記号は通ることを確認済 !?@#$%^&*()\-_=+{};:,<.>~
                ->uncompromised(3), // 右記サイトでデータ漏洩の実績が3回以下のパスワードであること https://haveibeenpwned.com/Passwords
            ],
        ];
    }
}
```

```php:resources/lang/ja/validation.php
<?php

return [
    'password' => [
        'letters' => ':attributeは、少なくとも1つの文字が含まれていなければなりません。',
        'mixed' => ':attributeは、少なくとも大文字と小文字を1つずつ含める必要があります。',
        'numbers' => ':attributeは、少なくとも1つの数字が含まれていなければなりません。',
        'symbols' => ':attributeは、少なくとも1つの記号が含まれていなければなりません。',
        'uncompromised' => 'この:attributeは過去に漏洩したことのある脆弱な:attributeです。別の:attributeを入力してください。',
    ]
];
```

## さらに追記

他フィールドの値を取得するには、DataAwareRule を使用するようにして、setDataで値をセットすれば利用可能になります。

```php:app/Rules/ValidDate.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

use Illuminate\Contracts\Validation\DataAwareRule; // ★追記

class ValidDate implements DataAwareRule, ValidationRule
{
    /**
     * バリデーション下の全データ
     *
     * @var array<string, mixed>
     */
    protected $data = [];

    /**
     * バリデーション下のデータをセット
     *
     * @param  array<string, mixed>  $data
     */
    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    /**
     * Run the validation rule.
     *
     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $year = $this->data['year'];
        $month = $this->data['month'];
        $day = $value;

        $isValidDate = checkdate($month, $day, $year);

        if(! $isValidDate) $fail("設定された日付は正しくありません");
    }
}
```

## さらにさらに追記

パスワードルールに違反した場合、それぞれのエラーメッセージを出すのはユーザーフレンドリーである一方で、悪意ある第三者にパスワードルールを予測され、攻撃されるセキュリティリスクを増大させる恐れがあります。
そのため、パスワードルールに抵触した場合は単に「メールアドレス、またはパスワードが違います。」とだけ表示するように改修します。

```php:app\Http\Requests\AuthRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

use Illuminate\Validation\Rules\Password;

class AuthRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        // パスワードルールオブジェクトについて
        // https://readouble.com/laravel/10.x/ja/validation.html#validating-passwords
        // https://laravel.com/api/10.x/Illuminate/Validation/Rules/Password.html
        return [
            'email' => ['required','email','max:255'],
            'password' => [
                'required',
                'max:255',
                Password::min(8)
                ->letters()   // 最低1文字の文字が必要
                ->mixedCase() // 最低大文字小文字が1文字ずつ必要
                ->numbers()   // 最低1文字の数字が必要
                ->symbols()   // 最低1文字の記号が必要 右記の記号は通ることを確認済 !?@#$%^&*()\-_=+{};:,<.>~
                ->uncompromised(3), // 右記サイトでデータ漏洩の実績が3回以下のパスワードであること https://haveibeenpwned.com/Passwords
            ],
        ];
    }

    public function messages()
    {
        return [
            'password.required' => trans('validation.required'),
            'password.max' => trans('validation.max.string'),
            'password.*' => trans('auth.invalid'),
        ];
    }
}
```

```php:resources/lang/ja/auth.php
<?php

return [
    'invalid' => 'メールアドレス、またはパスワードが違います。'
];
```

```php:resources/lang/ja/validation.php
<?php

return [
    'max' => [
        'numeric' => ':attributeには:max以下の数値を指定してください。',
        'file'    => ':attributeには:max KB以下のファイルを指定してください。',
        'string'  => ':attributeには:max文字以下の文字列を指定してください。',
        'array'   => ':attributeには:max個以下の要素を持つ配列を指定してください。',
    ],
    'required' => ':attributeは必須です。',
    'password' => [
        'letters' => ':attributeは、少なくとも1つの文字が含まれていなければなりません。',
        'mixed' => ':attributeは、少なくとも大文字と小文字を1つずつ含める必要があります。',
        'numbers' => ':attributeは、少なくとも1つの数字が含まれていなければなりません。',
        'symbols' => ':attributeは、少なくとも1つの記号が含まれていなければなりません。',
        'uncompromised' => 'この:attributeは過去に漏洩したことのある脆弱な:attributeです。別の:attributeを入力してください。',
    ]
];
```