TL:DR
- Controller ではなく Model でバリデーションするとして、どんなエラーをどう返したら良いか?
- Laravelのバリデーションの仕組み解説(ミニマム版)
- Laravelでバリデーションを 任意のところ で行う作法
- いやいやそもそもドメイン層でフレームワーク依存ってどうなんそれ?っていう話はあとがきで弁解。
なにがしたいのか?
いわゆる「バリデーション」はコントローラでやることになっているLaravelさん。
それを Modelで 実行するには?
できるだけLaravelっぽさを残して、少ないコードで実現する方法です。
結論
仕様
ModelにRulesを書く
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use ValidateOnSave;
protected $guarded = [ 'id' ];
// このように
protected function rules(){
return [
'name' => 'integer',
'password' => 'min:6',
];
}
}
saveで発動
$post = new Post;
$post->fill($request->all());
$post->message = 'xxxxxxxxxxxxxxxxx';
$post->save(); // ココでチェック エラー時は自動リダイレクト
実装
トレイト
namespace App;
trait ValidateOnSave
{
protected function rules(){
return [
];
}
public function save(array $options = [])
{
$rules = $this->rules();
if (!empty($rules)) {
$subject = $this->attributes;
$validator = Validator::make($subject, $rules);
if ($validator->fails()) {
// テスト時・コンソール実行時にはエラー内容を画面に表示する
if (app()->environment('testing') || app()->runningInConsole()) {
$errors = $validator->errors();
foreach ($rules as $attr => $rule) {
if ($errors->has($attr)) {
echo "\n\n------------ VALIDATION ERROR\n";
foreach ($errors->get($attr) as $message) {
echo "$message\n";
}
echo "RULE : '$attr' => '$rule' \n";
echo "VALUE: '$attr' => " . (array_key_exists($attr, $subject) ? var_export($subject[$attr], true) : 'undefined') . "\n";
}
}
}
throw new ValidationException($validator);
}
}
return parent::save($options);
}
}
2019-04 加筆
- PHPUnitで「The given data was invalid.」としかでないのが不便だなーと思っていたので、エラー内容を出力(エラーハンドラに書いたらカッコいいかと思ったのですがPHPUnit時は効かなかったので)。
- saveの返り値をスルーしていました。そもそも、saveの返り値って何だっけ?
E 1 / 1 (100%)
------------ VALIDATION ERROR
src idは必ず指定してください。
RULE : 'src_id' => 'required|string'
VALUE: 'src_id' => ''
1) Tests\\ValidateOnSaveTest::exceptionTest
Illuminate\Validation\ValidationException: The given data was invalid.
/app/Extensions/Eloquent/Tools/ValidateOnSave.php:32
使用例
モデル
「仕様」に書いたように、上記のトレイトをモデルに組み込んで、ルールを書きます。
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use ValidateOnSave;
protected $guarded = [ 'id' ];
// このように
protected function rules(){
return [
'name' => 'integer',
'password' => 'min:6',
];
}
}
コントローラ
resourcefulコントローラとして作成したものです。特に目新しいことはしていません。もちろんバリデーションは書きません。
$ php artisan make:controller PostController --resource
public function store(Request $request)
{
$post = new Post;
$post->fill($request->all());
$post->save();
return view('show',compact('post'));
}
結果
コントローラでバリデーションを書いていませんが、フォームをPOSTすると下記のようにバリデーションエラーが返ってきます。
HTMLフォーム
表示自体にとくに目新しさはありません……。
(文字が赤くないのでエラーっぽくない……)
API JSON
フォームではなく API としてAJAXリクエストする(JSONを要求する)と自動的にJSONで返してくれます。
Laravel標準のバリデーションエラー書式ですね。
※キャプチャは chrome extention の Advanced REST Client です。
解説
5秒で説明する Laravelバリデータの仕組み
Laravelのバリデータは、
- 配列を
- ルールでチェックし
- エラーがあれば「例外」を飛ばし
ワープ!!
- エラーハンドラがキャッチして
- (web)リダイレクト / (api)エラー出力
しています。
1行でできるバリデーション
\Validator::validate( $this->attributes, $rules );
なので、チェックしたい配列と、ルールの配列を用意すれば、あとはこの1行を書くだけで、いつでもどこでもバリデーションが実行されて、その後の処理はLaravelがうまいことやってくれるわけです。
#それがあまりにエレガントなのでほとんどのケースはこれで足りてしまうのですが、「さすがに例外を投げたりはしてくれなくていいよ、チェックだけして欲しい」という場合には
$validator = \Validator::make( $this->attributes, $rules );
$validator->passes(); // 成功したら true
$validator->fails(); // 失敗したら true
というメソッドが便利です。
補足説明
エラーハンドラの図にも書いていますが、ValidationエラーをWebで発動すると「1つ前のページにリダイレクト」されます。通常はこの方法で問題になりませんが、例えば index のような検索系ページ(操作元のページURLと次のページのURLが一緒)で更新するようなケースで リダイレクトループ になることがあります。これはどちらかといえば、エラーハンドラ側のやり方に問題があるため(いやいやそもそも検索ページで更新するなと)、App配下のエラーハンドラで、たとえば「URLが同じ場合はリダイレクトしない」といったルールを追加して回避することができます。
// このメソッドを追加(Webでバリデーションエラー時の振る舞いをオーバーライド)
protected function invalid($request, ValidationException $exception)
{
$url = $exception->redirectTo ?? url()->previous();
if( $url == url()->current()){
return $this->prepareResponse($request, $exception);
}
return parent::invalid($request, $exception);
}
そもそも、開発段階では ValidationException はModelの取扱ミス(ロジックエラー)であることが多いので、リダイレクトせず、すべてふつうに例外として画面表示させるだけにしておいたほうが良いかもしれません。
// このメソッドを追加(Webでバリデーションエラー時の振る舞いをオーバーライド)
protected function invalid($request, ValidationException $exception)
{
return $this->prepareResponse($request, $exception);
}
古いLaravel(うちだ)では、invalid メソッドがありませんでした…。その場合はそのひとつ前のメソッドをオーバーライドしちゃいましょう。
// このメソッドを追加(バリデーションエラー時の振る舞いをオーバーライド)
protected function convertValidationExceptionToResponse(ValidationException $e, $request)
{
if ($e->response) {
return $e->response;
}
return $this->prepareResponse($request, $e);
}
APIだったら、常にJSONでエラーを返すようにすることで全面的に回避できます。下記にまとめました。
Laravel APIで常にJSONをリクエストするミドルウェア
こんなレスポンスが返ります。一見「おいおいエラーコードもメッセージもなきゃ、バリデーションエラーかどうかわかんねぇじゃねえか」と思いますが、HTTPステータスコードが422になっています。そこをチェックしてください。
ポエム
バリデーションはどこでだれがやるのか?
最近、DDDやクリーンアーキテクチャを勉強しているのですが、それによると、いわゆる「バリデーション=ある変数にどんな値を入れても良いのかをチェック」する責任はドメイン層にある、とのこと。
ふりがなに入るのは、ひらがなじゃなくてカタカナ。
EMAILやIPアドレスは規定のフォーマット。
電話番号にハイフンは入らない。
価格だったら、マイナスにならない。
パスワードは大文字と記号を1つ以上含む。
カテゴリに選択できるのは登録済のカテゴリのIDのみ……。
こういったデータの基本的なルールを、まさかデータが送られてきたところでチェックしてませんよね?
それ、すべての入り口でチェックするんですか?
ルールが変わったり増えたら、それ全部修正するんですか? 修正漏れとか矛盾とか起こりますよ?
だからデータを保存するところでやりましょう。
どこから入ってきたとしても、常に共通のルールで、規格外を弾くようにするんですよ。
入り口でのチェックは必要最小限で済みますよー。
と。
しかしLarvelはコントローラの前でやる
しかし、Laravelはそんな設計になっていません。
WEBフォームだろうとAPIだろうと、データがサーバーに入ってきたところで入ってきた直後にチェックが入ります。
いや、それはそれで、それなりに理に適っているところはあると思うんです。
でも今回その話は、また次の機会に持ち越します。
せっかくなので、DDDの言うところのドメイン層≒Eloquentモデルでやってみることにしました。
しかしLarvelは標準でバリデーションエラーの作法がある
今回の主題はコチラです。
Modelでエラーチェックをするとして、どのようなエラーを発生させたらいいのか。
もちろん、アプリケーション独自のルールを決めたら良いのでどんなエラーでも良いんですが、せっかくならLaravelの作法に倣っておきたいところ。そしたら、エラーが返って来たコントローラ側で別途エラー処理を実装しなくても、標準のエラー処理がうまいことやってくれるんじゃないかと。
それが今回の $validator->validate()
を使った理由です。もちろんもう少しプリミティブに throw new ValidationException
とする方法もありかもしれませんが、この場合はそれに渡すオブジェクトの書き方とかも考慮する必要があり、今回は時間とボリュームの都合で割愛……。
あとがきと次回予告
Modelでバリデーション、ということであれば、世の中にはいくつかの優れた記事があります。
- Model で Validation したい? それならば Ardent だ!
- Laravel 5 Model Validation With the Esensi Model Traits Package
なので、この記事を書いた目的は車輪の再発明ではなくて、
「LaravelでDDDやるのに、ほんとにバリデーションはコントローラじゃダメなのか?」
「Laravelでクリーンアーキテクチャやるのに、ほんとにEloquentは分離しないとダメなのか?」
といったことを考えるキッカケに……というものです。
最近はずっと、仕事で業務システム向けに、Laravelでのバリデーションやフォームリクエストのベストプラクティスを考えていて、そのためにバリデーション周りのコードを深く深く読み込んで、なんとなくその仕組みを掴んだときにふと思いついた、副産物的なアイデアをまとめたものです。
その
- バリデーションのベストプラクティス
- LaravelでDDD的なことをするLaravel的なアプローチ
というものを、近いうちにまとめて記事にしたなーと考えています。
「ドメイン層でフレームワーク依存ってどうなん?」
に対する回答は、後者が
「あえてフレームワーク依存をがっつり切り離さなければ、こんなスッキリ軽く書けますよ」
というお話になる予定なので、
今は「こいつわかってねぇな」と思ったとしても、暖かい目で見守っていてほしいなーというところです……。