Help us understand the problem. What is going on with this article?

Modelでバリデーションする(1行で) + Laravel5のバリデーションの仕組み解説(5秒で)

TL:DR

  • Controller ではなく Model でバリデーションするとして、どんなエラーをどう返したら良いか?
  • Laravelのバリデーションの仕組み解説(ミニマム版)
  • Laravelでバリデーションを 任意のところ で行う作法
  • いやいやそもそもドメイン層でフレームワーク依存ってどうなんそれ?っていう話はあとがきで弁解。

なにがしたいのか?

いわゆる「バリデーション」はコントローラでやることになっているLaravelさん。
それを Modelで 実行するには?
できるだけLaravelっぽさを残して、少ないコードで実現する方法です。

結論

仕様

ModelにRulesを書く

app/Post.php
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();   // ココでチェック エラー時は自動リダイレクト

実装

トレイト

app\ValidateOnSave.php
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の返り値って何だっけ?
PHPUnit
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

使用例

モデル

「仕様」に書いたように、上記のトレイトをモデルに組み込んで、ルールを書きます。

app/Post.php
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
app\Http/Controllers/PostController.php
    public function store(Request $request)
    {
        $post = new Post;
        $post->fill($request->all());
        $post->save();
        return view('show',compact('post'));
    }

結果

コントローラでバリデーションを書いていませんが、フォームをPOSTすると下記のようにバリデーションエラーが返ってきます。

HTMLフォーム

image.png

表示自体にとくに目新しさはありません……。
(文字が赤くないのでエラーっぽくない……)

API JSON

image.png

フォームではなく API としてAJAXリクエストする(JSONを要求する)と自動的にJSONで返してくれます。
Laravel標準のバリデーションエラー書式ですね。

※キャプチャは chrome extention の Advanced REST Client です。

解説

5秒で説明する Laravelバリデータの仕組み

名称未設定-2.png

Laravelのバリデータは、

  1. 配列を
  2. ルールでチェックし
  3. エラーがあれば「例外」を飛ばし

 ワープ!!

  1. エラーハンドラがキャッチして
  2. (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が同じ場合はリダイレクトしない」といったルールを追加して回避することができます。

app/Exceptions/Handler.php
    // このメソッドを追加(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の取扱ミス(ロジックエラー)であることが多いので、リダイレクトせず、すべてふつうに例外として画面表示させるだけにしておいたほうが良いかもしれません。

app/Exceptions/Handler.php
    // このメソッドを追加(Webでバリデーションエラー時の振る舞いをオーバーライド)
    protected function invalid($request, ValidationException $exception)
    {
        return $this->prepareResponse($request, $exception);
    }

古いLaravel(うちだ)では、invalid メソッドがありませんでした…。その場合はそのひとつ前のメソッドをオーバーライドしちゃいましょう。

app/Exceptions/Handler.php
    // このメソッドを追加(バリデーションエラー時の振る舞いをオーバーライド)
    protected function convertValidationExceptionToResponse(ValidationException $e, $request)
    {
        if ($e->response) {
            return $e->response;
        }
        return $this->prepareResponse($request, $e);
    }

APIだったら、常にJSONでエラーを返すようにすることで全面的に回避できます。下記にまとめました。

Laravel APIで常にJSONをリクエストするミドルウェア

image.png

こんなレスポンスが返ります。一見「おいおいエラーコードもメッセージもなきゃ、バリデーションエラーかどうかわかんねぇじゃねえか」と思いますが、HTTPステータスコードが422になっています。そこをチェックしてください。

image.png

ポエム

バリデーションはどこでだれがやるのか?

名称未設定-1.png

最近、DDDやクリーンアーキテクチャを勉強しているのですが、それによると、いわゆる「バリデーション=ある変数にどんな値を入れても良いのかをチェック」する責任はドメイン層にある、とのこと。

ふりがなに入るのは、ひらがなじゃなくてカタカナ。
EMAILやIPアドレスは規定のフォーマット。
電話番号にハイフンは入らない。
価格だったら、マイナスにならない。
パスワードは大文字と記号を1つ以上含む。
カテゴリに選択できるのは登録済のカテゴリのIDのみ……。

こういったデータの基本的なルールを、まさかデータが送られてきたところでチェックしてませんよね?
それ、すべての入り口でチェックするんですか?
ルールが変わったり増えたら、それ全部修正するんですか? 修正漏れとか矛盾とか起こりますよ?

だからデータを保存するところでやりましょう。
どこから入ってきたとしても、常に共通のルールで、規格外を弾くようにするんですよ。
入り口でのチェックは必要最小限で済みますよー。

と。

しかしLarvelはコントローラの前でやる

しかし、Laravelはそんな設計になっていません。
WEBフォームだろうとAPIだろうと、データがサーバーに入ってきたところで入ってきた直後にチェックが入ります。

いや、それはそれで、それなりに理に適っているところはあると思うんです。
でも今回その話は、また次の機会に持ち越します。

せっかくなので、DDDの言うところのドメイン層≒Eloquentモデルでやってみることにしました。

しかしLarvelは標準でバリデーションエラーの作法がある

今回の主題はコチラです。
Modelでエラーチェックをするとして、どのようなエラーを発生させたらいいのか。

もちろん、アプリケーション独自のルールを決めたら良いのでどんなエラーでも良いんですが、せっかくならLaravelの作法に倣っておきたいところ。そしたら、エラーが返って来たコントローラ側で別途エラー処理を実装しなくても、標準のエラー処理がうまいことやってくれるんじゃないかと。

それが今回の $validator->validate() を使った理由です。もちろんもう少しプリミティブに throw new ValidationException とする方法もありかもしれませんが、この場合はそれに渡すオブジェクトの書き方とかも考慮する必要があり、今回は時間とボリュームの都合で割愛……。

あとがきと次回予告

Modelでバリデーション、ということであれば、世の中にはいくつかの優れた記事があります。

なので、この記事を書いた目的は車輪の再発明ではなくて、
「LaravelでDDDやるのに、ほんとにバリデーションはコントローラじゃダメなのか?」
「Laravelでクリーンアーキテクチャやるのに、ほんとにEloquentは分離しないとダメなのか?」
といったことを考えるキッカケに……というものです。

最近はずっと、仕事で業務システム向けに、Laravelでのバリデーションやフォームリクエストのベストプラクティスを考えていて、そのためにバリデーション周りのコードを深く深く読み込んで、なんとなくその仕組みを掴んだときにふと思いついた、副産物的なアイデアをまとめたものです。

その

  • バリデーションのベストプラクティス
  • LaravelでDDD的なことをするLaravel的なアプローチ

というものを、近いうちにまとめて記事にしたなーと考えています。

「ドメイン層でフレームワーク依存ってどうなん?」
に対する回答は、後者が
「あえてフレームワーク依存をがっつり切り離さなければ、こんなスッキリ軽く書けますよ」
というお話になる予定なので、
今は「こいつわかってねぇな」と思ったとしても、暖かい目で見守っていてほしいなーというところです……。

kd9951
インフラからデザインまで。ワンストップでWEBアプリケーション開発しているエンジニア。メインはPHP/Laravelでの業務系システム開発。WordpressやAugnlarあたりも守備範囲。自称「穴埋め係」でGoogleやQiitaに無かった記事を書くことが多いので、誰得なマニアックな記事が多いです(*´ω`*)    園芸が好きで「多肉植物図鑑PUKUBOOK」を制作し編集長をしています。
https://pukubook.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした