SymfonyのFormTypeにバリデーションをかける

# はじめに
Symfonyでは`Entity`にバリデーションを書くことができます。[公式ドキュメント](https://symfony.com/doc/current/validation.html#constraints)には以下のような例があります。

```php
// src/Entity/Author.php
namespace App\Entity;

// ...
use Symfony\Component\Validator\Constraints as Assert;

class Author
{
    /**
     * @Assert\NotBlank
     */
    private $name;
}
```

ですが、バリデーションは必ずしもEntityと一対一で紐づくものでもなく、Entityにマッピングされないリクエストでもバリデーションをかけたい時があります。
そんな時のために、実はバリデーションクラスは`FormType`にも適用することができます。

何故か公式ドキュメントには、`FormType`にバリデーションをかける方法が記載されていない（自分が見つけられていないだけ？）のですが、割と便利なので、よく使うバリデーションを紹介します。


# FormTypeの作成

まず、以下のような何もバリデーションのかかっていない`FormType`を作成します。

```php:src/Form/SomeFormType.php
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SomeFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('lastName', TextType::class, [
                'label' => '姓',
            ])
            ->add('firstName', TextType::class, [
                'label' => '名',
            ])
            ->add('birthday', DateType::class, [
                'label' => '生年月日',
                'widget' => 'single_text',
            ])
            ->add('gender', ChoiceType::class, [
                'label' => '性別',
                'choices' => [
                    '男性' => 'men',
                    '女性' => 'female',
                ],
            ])
            ->add('email', EmailType::class, [
                'label' => 'メールアドレス',
                'help' => 'メールアドレスか電話番号のどちらか一つは入力してください。',
                'required' => false,
            ])
            ->add('tel', TelType::class, [
                'label' => '電話番号',
                'help' => 'メールアドレスか電話番号のどちらか一つは入力してください。',
                'required' => false,
            ])
            ->add('isConfirmed', CheckboxType::class, [
                'label' => '利用規約に同意する',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([]);
    }
}

```
`Controller`と`template`は以下の通りです。

```php:src/Controller/FormTestController.php
<?php

namespace App\Controller;

use App\Form\SomeFormType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class FormTestController extends AbstractController
{
    /**
     * @Route("/form/test", name="form_test")
     */
    public function index(Request $request)
    {
        $form = $this->createForm(SomeFormType::class)->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $postData = $form->getData();
            // do something
        }
        return $this->render('form_test/index.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

```

```twig:templates/form_test/index.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<div class="container">
    <div class="card my-5">
        <div class="card-body">
            <div class="p-5">
                {{ form_start(form) }}
                {{ form_rest(form) }}
                <button class="btn btn-primary btn-block mt-5">Submit</button>
                {{ form_end(form) }}
            </div>
        </div>
    </div>
</div>
{% endblock %}
```



画面表示は以下のようになります。

![スクリーンショット 2021-09-27 23.37.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/9495ae18-0da4-625c-b6d1-cb712e006669.png)

今はバリデーションを何もかけていないので、次のような入力をしても、リクエストが通ってしまいます。
それでは、各フィールドにバリデーションを追加していきます。

![スクリーンショット 2021-09-27 23.40.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/a1cca279-acc6-6513-7284-3ee39c8b5b6a.png)



# バリデーションの追加

## 長さ・必須

姓名は、一般的にはそれぞれ10文字程度あれば十分です。そのような「長さに関するバリデーション」は`Length`クラスが利用できます。（※ 以降出てくるバリデーションクラスのnamespaceは`Symfony\Component\Validator\Constraints`です。同じ名前のクラスがある場合もあるので、namespaceを間違えないようにしてください。）

バリデーションクラスには、引数としてoptionを設定した配列を渡します。optionに設定できる値は各バリデーションごとに異なります。
`Length`には「文字列の最大長」を設定する`max`オプションと、「最大長を超えた時に表示するメッセージ」を設定する`maxMessage`などがあります。（もちろん`min`バージョンもあります）
`Length`に関する完全なオプションは[こちら](https://symfony.com/doc/current/reference/constraints/Length.html)を参照してください。

これらのバリデーションクラスは、以下のようにフォームのフィールドに`constraints`という名前で配列の形式で渡すことで適用することができます。

また、空文字列も許容したくないので、`NotNull`も同時に適用します。

```php
...
            ->add('lastName', TextType::class, [
                'label' => '姓',
                'constraints' => [
                    new Length([
                        'max' => 10,
                        'maxMessage' => '姓は最大{{ limit }}文字で入力してください。'
                    ]),
                    new NotNull([
                        'message' => '姓を入力してください。'
                    ]),
                ]
            ])
            ->add('firstName', TextType::class, [
                'label' => '名',
                'constraints' => [
                    new Length([
                        'max' => 10,
                        'maxMessage' => '名は最大{{ limit }}文字で入力してください。'
                    ]),
                    new NotNull([
                        'message' => '名を入力してください。'
                    ]),
                ]
            ])
...
```

フォームで不正な値を送信すると、このような表示になります。

![スクリーンショット 2021-09-28 0.53.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/f30b8631-f6a0-7b40-3d04-7854549b42a7.png)


![スクリーンショット 2021-09-28 0.52.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/565d8b03-8060-3331-664e-6d82badf5910.png)



## 比較

次に、年齢が20歳以上になるようにバリデーションをかけます。年齢が20歳以上になるためには、生年月日が本日から20年以上前になっていれば良いわけです。
このような「〇〇より小さい」などの比較を行う時は`LessThan`が利用できます。`LessThan`は、フォームから送られてきた値と比較する`value`と、バリデーションに失敗した時に表示する`message`オプションをとります。

ドキュメントは[こちら](https://symfony.com/doc/current/reference/constraints/LessThan.html)

今回は日付の比較を行なっていますが、PHPで比較可能なものであればOKです。

```php
...
            ->add('birthday', DateType::class, [
                'label' => '生年月日',
                'widget' => 'single_text',
                'constraints' => [
                    new LessThan([
                        'value' => new DateTime('20 years ago'),
                        'message' => '20歳未満は利用できません。'
                    ])
                ]
            ])
...
```

画面表示はこのようになります。


![スクリーンショット 2021-09-27 23.47.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/c7bbe5ee-3c44-971b-362e-493eb9999b2d.png)



## メールアドレス等

続いて、メールアドレスの形式にバリデーションをかけます。こちらはズバリ`Email`というクラスがあるので、こちらを使います。
`Email`には、バリデーションに失敗した時に表示する`message`オプションと、メールの検証に用いるルールを指定するための`mode`オプションがあります。
`mode`オプションに設定できる値は次のようになっています。

* `loose`: 正規表現で緩く検証を行う（非推奨）
* `html5`: HTML5の電子メール入力要素と同じ正規表現を使って検証を行う
* strict : `RFC 5322`にしたがって検証を行う。（別途`egulias/email-validator`ライブラリのインストールが必要）

ドキュメントは[こちら](https://symfony.com/doc/current/reference/constraints/Email.html)。

その他、特殊な文字列を検証するためのクラスとしては[Url](https://symfony.com/doc/current/reference/constraints/Url.html)、[Regex](https://symfony.com/doc/current/reference/constraints/Regex.html)、[Json](https://symfony.com/doc/current/reference/constraints/Json.html)、[Hostname](https://symfony.com/doc/current/reference/constraints/Hostname.html)、[Ip](https://symfony.com/doc/current/reference/constraints/Ip.html)などがあります。

が、電話番号はないので、日本で一番メジャーな10文字 or 11文字のバリデーションをかけます。

```php
...
            ->add('email', EmailType::class, [
                'label' => 'メールアドレス',
                'help' => 'メールアドレスか電話番号のどちらか一つは入力してください。',
                'required' => false,
                'constraints' => [
                    new Email([
                        'message' => 'メールアドレスの形式が不正です。',
                        'mode' => 'html5',
                    ])
                ]
            ])
            ->add('tel', TelType::class, [
                'label' => '電話番号',
                'help' => 'メールアドレスか電話番号のどちらか一つは入力してください。',
                'required' => false,
                'constraints' => [
                    new Length([
                        'max' => 11,
                        'min' => 10,
                        'minMessage' => '電話番号は10~11桁で入力してください。',
                        'maxMessage' => '電話番号は10~11桁で入力してください。',
                    ])
                ]
            ])
...
```

画面表示はこのようになります。

![スクリーンショット 2021-09-27 23.55.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/bcb69bb9-663c-67a0-6682-c1ce562bba6e.png)



## チェックボックス

続いて、チェックボックスにチェックが入っていることを検証します。一応HTMLの要素でバリデーションはかかっていますが、開発者ツールなどで容易く解除できてしまうので、サーバーでのバリデーションも行います。

チェックボックスがチェックされているか（ = `true`であるか）は`IsTrue`クラスで検証できます。オプションには、エラーメッセージを設定する`message`を渡します。

ドキュメントは[こちら](https://symfony.com/doc/current/reference/constraints/IsTrue.html)。

```php
...
            ->add('isConfirmed', CheckboxType::class, [
                'label' => '利用規約に同意する',
                'constraints' => [
                    new IsTrue([
                        'message' => 'チェックボックスにチェックを入れてください。'
                    ])
                ]
            ])
...
```

画面表示はこのようになります。

![スクリーンショット 2021-09-27 23.57.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/5db4cd09-84f5-9736-2645-85ce784969ba.png)



## 複数フィールドにまたがるバリデーション

最後に「メールアドレスか電話番号のどちらか一つは必須」という部分のバリデーションを行います。
今までは、フォームの各フィールドにバリデーションをかけてきましたが、このように「複数フィールドにまたがるバリデーション」を行うときは、フォーム全体のバリデーションとして設定します。

フォーム全体のバリデーションをかける方法は、`FormType`の`configureOptions()`メソッドで`FormType`全体のデフォルトオプションとしてバリデーションを追加するというものになります。このような複雑なバリデーションを行う時は`Callback`クラスを使うのが良いです。

`Callback`クラスは`callback`というオプション名で`callable`なものをとります。`callable`に渡ってくる引数は、第一引数が「フォームから送られてきた値の配列 or それらをEntityにマッピングしたもの」、第二引数が「現在のフォームのコンテキストクラス」になります。

以下のように、第一引数のデータを見て、第二引数のコンテキストにエラーを追加していく、という流れになります。

`FormType`を使っていると、このようなバリデーションを書きたくなることがよくあるのですが、わかりやすいサンプルが何故かドキュメントにないんですよね。。。。

一応ドキュメントは[こちら](https://symfony.com/doc/current/reference/constraints/Callback.html)になります。

※`FormType`と`Entity`のマッピングを行なっている時は、第一引数に渡ってくる値は配列ではなく、マッピングした`Entity`になるので注意です。

```php
...
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'constraints' => [
                new Callback([
                    'callback' => function (array $data, ExecutionContextInterface $context) {
                        $email = $data['email'];
                        $tel = $data['tel'];
                        if ($email === null && $tel === null) {
                            $context->buildViolation('メールアドレスか電話番号のどちらか一つは入力してください。')
                                ->atPath('[email]')
                                ->addViolation();
                            $context->buildViolation('メールアドレスか電話番号のどちらか一つは入力してください。')
                                ->atPath('[tel]')
                                ->addViolation();
                        }
                    },
                ]),
            ],
        ]);
    }
...
```

画面表示はこのようになります。

![スクリーンショット 2021-09-28 0.20.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614264/ee353837-32d5-9a7f-febb-68e4d6e28d02.png)


# 最後に

今回は、具体的な例を出しながら、`FormType`にバリデーションをかける方法を書きました。
ここで紹介した以外にも、さまざまなバリデーションクラスが用意されています。
https://symfony.com/doc/current/validation.html#supported-constraints

また、バリデーションクラスを自分で作成することももちろんできます。
https://symfony.com/doc/current/validation/custom_constraint.html

便利な機能なのですが、あまり紹介してある記事やドキュメントを見かけなかったので書いてみました。何かのお役に立てれば幸いです。