laravel5 日付入力のバリデーションはどう書くのが気持ちいいか

laravelのバリデーションルールはとても種類が多くてありがたいのですが、
それでも既存のバリデーションでは対応してないやり方をしたい場合もあります。

![7c5605de-1f49-0795-c0b0-e5d6f966bb8d.png](https://qiita-image-store.s3.amazonaws.com/0/108345/6ecb1198-ceb8-6c5c-7b2e-b8692f33236e.png)


マイページ系の画面でこんな感じの生年月日入力はよく見かけます。
これのバリデーションを考えたいと思います。
環境はlaravel5.3ですが、たぶん5系ならどれでもいけるかと思います。

# やりたいこと

- 生年月日を「年」「月」「日」別のプルダウンで入力させたい
- 入力必須項目ではないものとする（「年」「月」「日」が全て選択されてない場合は未入力）
- 「年」「月」「日」のどれかが選択されててどれかが選択されてない、という場合はエラー
- 「年」「月」「日」が全て選択されていて、不正な日付（02/30とか）の場合はエラー
- 上記2つのエラー表示を分けたい

# 前準備

- [エラーメッセージの日本語化](http://qiita.com/ponko2/items/f2f59b43dae1561ceb50#%E6%97%A5%E6%9C%AC%E8%AA%9E%E5%8C%96)

# 方針

- カスタムバリデーションでの実装はあまり気持ちいい書き方を見つけられなかった
- フォームリクエストバリデーションで ~~validate()~~ getValidatorInstance()をオーバーライドして、日付を連結したフィールドを作ることにした
- 連結したフィールドに標準のdateバリデーションをかけてチェック


# 実装

ごくシンプルな投稿コントローラー

```php:app\Http\Controllers\UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
a
use App\Http\Requests;

use App\Http\Requests\UserRequest;
use App\User;

class UserController extends Controller
{

    public function index()
    {
        return view('users.index');
    }

    public function store(UserRequest $request)
    {
        $user = User::create($request->all());

        if (!$user->birth) {
            $user->birth = null;
        }

        $user->save();
        return view('users.store');
    }

}
```


フォームリクエストの ~~validate()~~ getValidatorInstance() をオーバーライドして、
「birth_year」「birth_month」「birth_day」を連結した「birth」フィールドを作成。
あとは普通にbirthに対してdateバリデーションを行うようrules()に記述。
「年が入力されてて月が入力されてない」みたいなのはrequired_withで対応。

**[追記] laravel 5.4とか5.5あたりから、validate()では捕捉できなくなったようです。**
**getValidatorInstance()を使用します。**

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

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name_first'  => 'required',
            'name_last'   => 'required',
            'birth'       => 'date',
            'birth_year'  => 'required_with:birth_month,birth_day',
            'birth_month' => 'required_with:birth_year,birth_day',
            'birth_day'   => 'required_with:birth_year,birth_month',
        ];
    }

    public function getValidatorInstance()
    {
        if ($this->input('birth_day') && $this->input('birth_month') && $this->input('birth_year'))
        {
            $birthDate = implode('-', $this->only(['birth_year', 'birth_month', 'birth_day']));
            $this->merge([
                'birth' => $birthDate,
            ]);
        }

        return parent::getValidatorInstance();
    }
}
```


フィールドの日本語化設定をしておく。

```php:resources\lang\ja\validation.php（抜粋）
    'attributes' => [
        'name_last'       => '姓',
        'name_first'      => '名',
        'birth'           => '生年月日',
        'birth_year'      => '生年月日（年）',
        'birth_month'     => '生年月日（月）',
        'birth_day'       => '生年月日（日）',
    ],
```


テンプレート。
「birth_year」「birth_month」「birth_day」「birth」に対して別々にエラーを引ける。

```php:resources\views\users\index.blade.php（抜粋）
    <div class="form-group{{ $errors->has('birth') || $errors->has('birth_year') || $errors->has('birth_month') || $errors->has('birth_day') ? ' has-error' : '' }}">
        <label for="birth_year" class="col-md-4 control-label">生年月日</label>

        <div class="row">
            <div class="col-md-2">
                <select id="birth_year" class="form-control" name="birth_year">
                <option value="">----</option>
                @for ($i = 1980; $i <= 2005; $i++)
                <option value="{{ $i }}"@if(old('birth_year') == $i) selected @endif>{{ $i }}</option>
                @endfor
                </select>

                @if ($errors->has('birth_year'))
                    <span class="help-block">
                        <strong>{{ $errors->first('birth_year') }}</strong>
                    </span>
                @endif
            </div>

            <div class="col-md-2">
                <select id="birth_month" class="form-control" name="birth_month">
                <option value="">--</option>
                @for ($i = 1; $i <= 12; $i++)
                <option value="{{ $i }}"@if(old('birth_month') == $i) selected @endif>{{ $i }}</option>
                @endfor
                </select>

                @if ($errors->has('birth_month'))
                    <span class="help-block">
                        <strong>{{ $errors->first('birth_month') }}</strong>
                    </span>
                @endif
            </div>

            <div class="col-md-2">
                <select id="birth_day" class="form-control" name="birth_day">
                <option value="">--</option>
                @for ($i = 1; $i <= 31; $i++)
                <option value="{{ $i }}"@if(old('birth_day') == $i) selected @endif>{{ $i }}</option>
                @endfor
                </select>

                @if ($errors->has('birth_day'))
                    <span class="help-block">
                        <strong>{{ $errors->first('birth_day') }}</strong>
                    </span>
                @endif
            </div>
        </div>

        <div class="row col-md-6 col-md-offset-4">
        @if ($errors->has('birth'))
            <span class="help-block">
                <strong>{{ $errors->first('birth') }}</strong>
            </span>
        @endif
        </div>

    </div>
```


# こんな感じ

正しい日付の場合は普通に投入できる
![laravel1.png](https://qiita-image-store.s3.amazonaws.com/0/108345/0372de3d-d04f-0db3-d14e-10d2d0b558b8.png)

生年月日が全て未入力の場合も普通に投入できる
![laravel1.png](https://qiita-image-store.s3.amazonaws.com/0/108345/2cd12869-94a2-073f-b695-a552e446c0f8.png)

生年月日が一部しか入力されてない場合はエラー
※冗長なのでエラーメッセージは差し替えたほうがいいかも
![laravel1.png](https://qiita-image-store.s3.amazonaws.com/0/108345/583619b0-89ee-32ea-53bb-b0a61ecd4b5f.png)

生年月日が全部入力されてるけど正しい日付じゃない場合はエラー
![laravel1.png](https://qiita-image-store.s3.amazonaws.com/0/108345/e3ee000e-42c4-da92-7b28-71bf0da135ca.png)

# 気持いいポイント

- 最終的に標準のバリデーションルールしか使ってないのが気持ちいい
- 連結したbirthフィールドをそのままDBに突っ込めちゃうからコントローラーがすっきりして気持ちいい
- エラーを別々にできるのがとても気持ちいい（最終的に「日付が不正だ」というエラーを$errors->first('**birth_year**')で引いてくる、みたいなのは避けたかった（だって別に年のエラーじゃなく日付全体のエラーなわけだし））



# あとがき

laravel触りはじめてそんなに経ってない身ですので
「このやり方だとここが気持ちよくない」とか
「カスタムバリデーションならこうやれば気持ちいい」とか
「こうするともっと気持ちよくなる」とか
「俺のコードはまた別の気持ちよさが」とか
あれば是非教えて下さるとうれしいです。

### 参考リンク
https://laracasts.com/discuss/channels/general-discussion/l5-how-to-use-after-method-on-form-request