この記事は Symfony Advent Calendar 2014 の 24 日目の記事です。

つい最近初めてカスタムバリデーションを作ったので、おさらいを兼ねて基本的な作成方法を紹介します。

環境

  • Symfony 2.6
  • phpunit 4.3

今回例として作成するバリデーション

クライアントからPOSTされたデータをバリデーションでチェックします。

## (1) Symfony2標準のバリデーションを試してみる

フォームタイプを見る

バリデーションは通常、フォームと組み合わせて使用します。 フォームに関連付けられたエンティティがバリデーションを定義する場所です。

<?php
class fooType
{
  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    // フォームに関連付けられたエンティティ:Foo
    $resolver->setDefaults(['data_class' => 'AcmeBundle\Entity\Foo']);
  }
}

バリデーションを設定

アノテーションで制約名を指定します。 例として「メールアドレスとして正しいか」という制約を指定します。 (validation.ymlで定義することもできます)

<?php
use Symfony\Component\Validator\Constraints as Assert;

class Foo
{
  /**
  * @Assert\Email
  */
  private $email;
}

バリデーションを試す

では次にPOSTされたデータのチェックを行ってみます。

FooController.php

<?php
  public function updateAction(Request $request)
  {
    $form = $this->createForm(new FooType());

    // エラーがある場合、フォームを再描画する
    if (!$form->handleRequest($request)->isValid()) {
      return ['form' => $form->createForm()];
    }
  }

handleRequest()でPOSTされたデータをフォームにセットし、isValid()でエラーの検証を行います。 バリデーションはこの時に実行されます。

バリデーションの検証結果

エラーがある場合は自動的にフォームに表示されます。 とても簡単ですね。

image

バリデーションのメリット

  • エラーの内容を表示するコードを書かなくて済む
  • 複数のエラーが一度に表示されるので、入力し直して何度も送信ボタンを押す手間が減る

他の標準バリデーション

標準のバリデーションは他にNotNull, NotBlank, Length, True, Rangeなどがあります。 よく使うバリデーションはこれだけで事足りそうです。 http://symfony.com/doc/current/book/validation.html#basic-constraints

## (2) 複数フィールドにまたがるバリデーションを作成してみる

Callback制約を使うとメソッドを定義できるため、より柔軟なバリデーションが作成できます。

<?php
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class Foo
{
  /**
  * @Assert\Callback
  */
  public function validateContactAddress(ExecutionContextInterface $context)
  {
    if (empty($this->email) && empty($this->phone)) {
      $context->buildViolation('Eメールか電話番号のどちらかを指定してください。')
          ->addViolation()
      ;
    }
  }
}

結果はこのように表示されます

image

特定のフィールドにエラーを表示したい

atPath()でフィールドを指定します。

<?php
  if (empty($this->email) && empty($this->phone)) {
    $context->buildViolation('Eメールか電話番号のどちらかを指定してください。')
        ->atPath('email')
        ->addViolation()
    ;
  }

image

## (3) 依存を持ったカスタムバリデーションを作成してみる

もっと複雑なバリデーションの場合はカスタムバリデーションを定義することができます。

ファイルの作成

カスタムバリデーションには、制約・バリデータの2つが必要です。

Symfony2は指定された制約と同名のバリデータを検索するため、ファイル名が揃っている必要があります。 (後から変更できます)

- AcmeBundle
    |--Validator
        |--Constraints
            |--UnusedEmail.php
            |--UnusedEmailValidator.php

制約の作成

制約とは、特定の要件を満たしていることの表明です。 例えばこの例では「未使用のEメールアドレス」を表明しています。

UnusedEmail.php

<?php
use Symfony\Component\Validator\Constraint;

// 制約をアノテーションで使用できるようにしたい場合は@Annotationを宣言します

/**
* @Annotation
*/
class UnusedEmail extends Constraint
{
  public $message = '他のサービスで使用されています。';
}

バリデータの作成

バリデータには実際に検証する内容を記述します。 まずは依存を持たない状態でシンプルに実装してみます。

UnusedEmailValidator.php

<?php
use Symfony\Component\Validator\ConstraintValidator;

class UnusedEmailValidator extends ConstraintValidator
{
  public function validate($value, Constraint $constraint)
  {
    if (...) {
      $this->context
        ->buildViolation($constraint->message)
        ->addViolation()
      ;
    }
  }
}

エンティティに設定

<?php
use AcmeBundle\Validator\Constraints;

class Foo
{
  /**
  * @Constraints\UnusedEmail
  */
  private $email;
}

試してみる

image

依存を持たせる

カスタムバリデーションは依存を持つことができます。 例えばentityManagerに依存して他のエンティティとの整合性を表明するなど、複雑なバリデーションを行うことができます。

サービスに登録

バリデータをサービスに登録しargumentsに依存オブジェクトを渡します。

# Resources/config/services.yml

acme.constraints.validator.unused_email:
  class: AcmeBundle\Validator\Constraints\UnusedEmailValidator
  arguments:
    - @doctrine.orm.entity_manager
  tags:
    - { name: validator.constraint_validator, alias: unused_email }

validator.constraint_validatorタグとalias属性は必須です。 忘れないように注意してください。

制約にバリデーションの参照を追加

サービスで指定したエイリアス名と同じテキストを返すようにします。

UnusedEmail.php

<?php
use Symfony\Component\Validator\Constraint;

class UnusedEmail extends Constraint
{
  ...

  public function validatedBy()
  {
    return 'unused_email';
  }
}

バリデータのコンストラクタで依存を受け取る

UnusedEmailValidator.php

<?php
use Symfony\Component\Validator\ConstraintValidator;

class UnusedEmailValidator extends ConstraintValidator
{
  private $entityManager;

  public function __construct(EntityManagerInterface $entityManager)
  {
    $this->entityManager = $entityManager;
  }

  ...
}

バリデータの検証を実装する

UnusedEmailValidator.php

<?php
class UnusedEmailValidator extends ConstraintValidator
{
  ...

  public function validate($value, Constraint $constraint)
  {
    $foo = $this->entityManager->getRepository('barBundle:bar')->findBy(['email' => $value]);

    if (!empty($foo)) {
      $this->context
        ->buildViolation($constraint->message)
        ->addViolation()
      ;
    }
  }
}

結果

image

## (4) ユニットテストを書いてみる

カスタムバリデーションを作成したらユニットテストを書きましょう。

今回作成したバリデータはコンテキストを参照しているのでinitialize()でモックを渡します。

<?php
  $mockContext = $this->getMockBuilder('Symfony\Component\Validator\Context\ExecutionContext')
    ->disableOriginalConstructor()
    ->getMock()
  ;

  $foo = new Foo();
  $foo->setEmail('test');
  $foo->setPhone('000-000-0000');

  $mockContext->expect($this->atLeastOne())->method('addViolation');

  $validator = new UnusedEmailValidator([依存オブジェクト]);
  $validator->initialize($mockContext);
  $validator->validate($foo, new UnusedEmail());

まとめ

実際はまわりの人の手を借りながら試行錯誤で実践したバリデーションでした。 コードの再利用度より実装にかける時間がはるかに上回ってしまいましたが、なんとなく書き方がマスターできたような気がするのでいい経験でした。 バリデーションに限らずSymfonyでは「設定はこのファイル、クラスの置き場はここ、サービス定義はここ」と置き場所がはっきり決まっていてすっきりきれいに整頓できるのが個人的に好きです。