62
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-10-19

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的なアプローチ

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

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

62
50
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?