リクエストパラメータ等のバリデーションでrules()などを利用して、バリデーションをしていると思います。その他にもLaravel内でバリデーションをしているところはいくつかあるかと思います。
バリデーションについては、こちらのドキュメントが参考になると思います(Laravel 8.0の場合)
さて、バリデーションに引っかかった場合に投げられる例外について見ていきたいと思います。
現状のValidationExceptionの仕組み
現在最新のLaravel 8.x系で見てみると、ValidationExceptionは\Exceptionを継承しています。
<?php namespace Illuminate\Validation; use Exception; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator as ValidatorFacade; class ValidationException extends Exception { // 以下省略
投げられる例外で個人的に一番重要だと思っているのがメッセージです。では、ValidationExceptionではそのmessageがどうなっているでしょうか?
\Exceptionの__construct()の第一引数が$messageになるので、__construct()が呼ばれている処理を確認してみます。するとどうでしょう。
/**
* Create a new exception instance.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @param \Symfony\Component\HttpFoundation\Response|null $response
* @param string $errorBag
* @return void
*/
public function __construct($validator, $response = null, $errorBag = 'default')
{
parent::__construct('The given data was invalid.');
$this->response = $response;
$this->errorBag = $errorBag;
$this->validator = $validator;
}
おやおや、'The given data was invalid.'の固定が引数で渡されていることがわかります。
これは大問題です。どんなバリデーションルールでNGだったのかというのがメッセージからはわからなくなっています。常に'The given data was invalid.'しか返ってこないので、そのままエラーログに出力していたら、例えばどのリクエストパラメータが誤っているのかがわかりません。
バリデーションエラーの内容を知る方法
では、ValidationExceptionが投げられた場合、ログやレスポンスにコケたルールを出力することはできないのか?
ValidationExceptionには、このようなメソッドが用意されています。
/**
* Get all of the validation error messages.
*
* @return array
*/
public function errors()
{
return $this->validator->errors()->messages();
}
このerrors()では、ルールに引っかかったものが配列で格納されています。そのため、App\Exceptions\Handlerなどで、report()やcontext()する時に、そのExceptionにerrors()のメソッドが存在すれば(method_exists())errors()を呼んで、その戻り値も含めるような処理を書いてあげれば、ログやレスポンスにNGだったルールを含めることが可能になります。
ValidationExceptionのmessageを動的に変える
上ではHandlerでカスタマイズする方法について書きました。ですが、そもそもValidationException内で固定でメッセージをセットしているのが問題です。
そんな問題に対して、以下のようなPull requestがマージされました。
[9.x] Include validation errors in ValidationException::$message #35524
先程紹介したerrors()で取得できる文字列の配列を__construct()内で取得して、加工して\Exceptionの__construct()に渡してあげようというものになります。
public function __construct($validator, $response = null, $errorBag = 'default')
{
- parent::__construct('The given data was invalid.');
+ parent::__construct(static::summarize($validator));
$this->response = $response;
$this->errorBag = $errorBag;
@@ -77,6 +77,31 @@ public static function withMessages(array $messages)
}));
}
+ /**
+ * Create a summary error message from the validation errors.
+ *
+ * @param \Illuminate\Contracts\Validation\Validator $validator
+ *
+ * @return string
+ */
+ protected static function summarize($validator)
+ {
+ $messages = $validator->errors()->all();
+
+ if (! count($messages)) {
+ return 'The given data was invalid.';
+ }
+
+ $message = array_shift($messages);
+
+ if ($additional = count($messages)) {
+ $pluralized = 1 === $additional ? 'error' : 'errors';
+ $message .= " (and $additional more $pluralized)";
+ }
+
+ return $message;
+ }
この対応が入ったことで、今までは'The given data was invalid.'の固定のmessageでしたが、errors()の最初のものがmessageに入ることになりました。また、その他にもNGなルールがあれば、個数も添えてくれます。
messageの例
では、動的にセットされるメッセージにはどういうものがあるのかを見ていきたいと思います。
先程のPull requestで書かれているValidationExceptionTestのテストコードを見てみたいと思います。
errors()はgetExceptionの第2引数でセットするようになっています。
protected function getException($data = [], $rules = [])
{
$translator = new Translator(new ArrayLoader, 'en');
$validator = new Validator($translator, $data, $rules);
return new ValidationException($validator);
}
エラーが0個
public function testExceptionSummarizesZeroErrors()
{
$exception = $this->getException([], []);
$this->assertEquals('The given data was invalid.', $exception->getMessage());
}
エラーがない場合は、今まで通り'The given data was invalid.'がセットされます。
エラーが1個
public function testExceptionSummarizesOneError()
{
$exception = $this->getException([], ['foo' => 'required']);
$this->assertEquals('validation.required', $exception->getMessage());
}
エラーが1個の場合、対象のルール違反についてのメッセージ('validation.required')がセットされます。
エラーが2個
public function testExceptionSummarizesTwoErrors()
{
$exception = $this->getException([], ['foo' => 'required', 'bar' => 'required']);
$this->assertEquals('validation.required (and 1 more error)', $exception->getMessage());
}
エラーが2個の場合、対象のルール違反に加えて、もう一つのエラーがあることをメッセージにセットされていることがわかります。
('validation.required (and 1 more error)')
これが先程紹介した、最初のNGなルールとそれ以外の個数がセットされるということですね。
エラーが3個以上
public function testExceptionSummarizesThreeOrMoreErrors()
{
$exception = $this->getException([], [
'foo' => 'required',
'bar' => 'required',
'baz' => 'required',
]);
$this->assertEquals('validation.required (and 2 more errors)', $exception->getMessage());
}
エラーが2個の時と似ていますが、それ以外の個数のところに変化があります。また、細かいところですが2 more errorsとerrorsが複数形に変わっています。
Laravel 9.xから動的にメッセージがセットされる
このPull requestはmasterブランチにマージされています。PRの名前からもわかるように、すなわちLaravel 9.0に含まれることになります。
Laravel 9.0は2022年1月リリース予定なので、その時から使えるようになります。
Laravel 8.xで動的なメッセージが適用されないのか?
Laravel 9まで待てない!Laravel 8.xに入れてくれよ!って思うかもしれません。自分も思いました。
それについてのディスカッションはLaravel 8.xのブランチにマージしようとするPull Requestに書かれていました。
[8.x] Include validation errors in ValidationException::$message #35496
この対応に対して、以下のようなコメントが書かれています。
Technically this is a breaking change because the expected output has now changed which is something that could catch people off guard. I think if this is to be done it's best that it's sent to master.
これに加えて以下のコメントも書かれています。
Yeah, I don't think we can change this on a patch release.
期待している出力が変わってしまうため、Laravel利用者に不意を付くことになってしまう。こういう修正はパッチリリースでは含むことができないので、masterブランチへのPull Requestを作り直して、メジャーバージョンのアップデートに含めるほうが良さそうだ。
という感じで書かれています。
自分自身、このコメントに学びがありました。メジャーバージョンのマイナーバージョンのアップデートに含めるものについて、こういう期待している出力が変わるものはマイナーバージョンアップには含めることができないんだと。たしかに、マイナーバージョンアップでこのような変更があったら、挙動が変わってしまいます。当たり前かもしれませんが、大事なことに気づくことができたPull Requestになりました。
まとめ
ValidationExceptionのメッセージはわかりにくいですが、Laravel 9.xがリリースされれば改善されます。リリースが待てず、今使っているLaravel 6.xのような現行バージョンのValidationExceptionをもっとわかりやすくしたい場合は、Handlerで出力をカスタマイズして、バリデーションエラーの内容を含めるようにすると良さそうですね。