How to use Symfony2 validator component without forms (Entities and data arrays)

There seems to be a lot of confusion when using Symfony2 validation outside CRUD and Forms but the thing is that these component can be used in many ways to validate stuff all around the application.

I will show 2 examples of how we use validation component at Ulabox for 2 quite common issues in e-commerce:

  • Credit Card Validation using Entity Classes (without being linked to a table) and Annotations
  • Customer Register array data validation

Validating Credit Cards Example

For this example I will use Entity classes (although they are not linked to Doctrine2 on any database) and annotations. There will be an abstract entity CreditCard and 3 entities extending from it (Visa, Mastercard and American Express)

So the 4 Entities created look like this:

  • Abstract class CreditCard
 1<?php
 2namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 3
 4use Symfony\Component\Validator\Constraints as Assert;
 5
 6abstract class CreditCard
 7{
 8    /**
 9     * @Assert\NotBlank()
10     */
11    public $cardnumber;
12
13    /**
14     * @Assert\NotBlank()
15     * @Assert\Regex(pattern="/^[0-9]{3,4}$/", message="payment.validations.cvv.invalidcvv")
16     */
17    public $cvv;
18
19    /**
20     * Format needs to be yymm
21     *
22     * @Assert\NotBlank()
23     * @Assert\Regex(pattern="/^[0-9]{4}$/", message="payment.validations.expmonth.invalidexpdate")
24     */
25    public $expirydate;
26
27    /**
28     * This is based in Luhn Algorithm
29     * @see http://en.wikipedia.org/wiki/Luhn_algorithm
30     *
31     * @Assert\True(message="payment.validations.cardnumber.checksum")
32     * @return bool
33     */
34   public function isChecksumCorrect()
35   {
36        $cardnumber = $this->cardnumber;
37        
38        $aux = '';
39        foreach (str_split(strrev($cardnumber)) as $pos => $digit) {
40            // Multiply * 2 all even digits
41            $aux .= ($pos % 2 != 0) ? $digit * 2 : $digit;
42        }
43        // Sum all digits in string
44        $checksum = array_sum(str_split($aux));
45        
46        // Card is OK if the sum is an even multiple of 10 and not 0
47        return ($checksum != 0 && $checksum % 10 == 0);
48    }
49
50    /**
51     * @Assert\True(message="payment.validations.expmonth.cardexpired")
52     * @return bool
53     */
54    public function isExpirationDateValid()
55    {
56        if(substr($this->expirydate, 2, 2) &lt; 1 || substr($this->expirydate, 2, 2) > 12) return false;
57        if($this->expirydate &lt; date('ym')) return false;
58    }
59}
  • Visa extending from CreditCard
 1<?php
 2
 3namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 4
 5use Symfony\Component\Validator\Constraints as Assert;
 6
 7class Visa extends CreditCard
 8{
 9    /**
10     * @Assert\Regex(pattern="/^4[0-9]{12}(?:[0-9]{3})?$/", message="payment.validations.cardnumber.invalidvisa")
11     */
12    public $cardnumber;
13}
  • Mastercard extending from CreditCard
 1<?php
 2
 3namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 4
 5use Symfony\Component\Validator\Constraints as Assert;
 6
 7class Mastercard extends CreditCard
 8{
 9    /**
10     * @Assert\Regex(pattern="/^5[1-5][0-9]{14}$/", message="payment.validations.cardnumber.invalidmastercard")
11     */
12    public $cardnumber;
13}
  • Amex extending from CreditCard
 1<?php
 2
 3namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 4
 5use Symfony\Component\Validator\Constraints as Assert;
 6
 7class Amex extends CreditCard
 8{
 9    /**
10     * @Assert\Regex(pattern="/^3[47][0-9]{13}$/", message="payment.validations.cardnumber.invalidamex")
11     */
12    public $cardnumber;
13}
  • And my code in my service layer is as follows:
 1<?php
 2
 3/**
 4 * Imagine that we are receiving a Card Data somewhere and our class we have set
 5 *   protected $method;
 6 *   protected $cardnumber;
 7 *   protected $cvv;
 8 *   protected $expirydate;
 9 *   protected $cardholder;
10 */
11
12use Ulabox\PaymentBundle\Services\TPV\Entity;
13
14    /**
15     * Returns entity to Validate payment params (like cardnumber format, etc...)
16     *
17     * @return Entity\CreditCard interface entity
18     * @throws HttpException 400 if invalid payment method is supplied
19     */
20    protected function getCreditCardEntity()
21    {
22        switch ($this->method) {
23            case 'visa':
24                return new Entity\Visa();
25                break;
26            case 'mastercard':
27                return new Entity\Mastercard();
28                break;
29            case 'amex':
30                return new Entity\Amex();
31                break;
32            default:
33                throw new HttpException(400, 'Payment method ' . $this->method . ' not developed as Entity. Check Services/TPV/Entity');
34        }
35    }
36
37    /**
38     * Validates payment data with Symfony2 standard Validator service
39     *
40     * @throws HttpException 400 if invalid data is found in validator
41     */
42    protected function validatePaymentData()
43    {
44        $creditcard = $this->getCreditCardEntity();
45
46        $creditcard->cardnumber = $this->cardnumber;
47        $creditcard->cvv = $this->cvv;
48        $creditcard->expirydate = $this->expirydate;
49
50        /**
51         * Validate expects an object with all constraints defined in it and just validates its properties to what is expected to satisfy
52         */
53        $errors = $this->container->get('validator')->validate($creditcard);
54
55        if (count($errors) > 0) {
56            throw new HttpException(400, $errors[0]->getMessage());
57        }
58    }

Can this be cleaner? We just define constraints using annotations, get the Entity class to test and just call validate with the object recovered. I think the code is pretty self explanatory, but in case you have any doubts please feel free to comment.

Please note that errors are coded like this: message=“payment.validations.cardnumber.invalidamex” so that we can later translate in Twig with {{ error | trans }}:

(This is content for Resources/translations/messages.en.yml but could be applied to any language used in our web application)

 1payment:
 2  validations:
 3    cardnumber:
 4      invalidvisa: This is not a valid VISA number
 5      invalidmastercard: This is not a valid MASTERCARD number
 6      invalidamex: This is not a valid AMEX number
 7      checksum: Invalid credit card number
 8    cvv:
 9      invalidcvv: Invalid CVV
10    expmonth:
11      cardexpired: Credit card expired
12      invalidexpdate: Invalid expiration date

Validating Register Data array

In this example I will deal directly with the request data array and I will define the constraint with good old plain PHP

 1<?php
 2use Ulabox\CoreBundle\Entity\Customer;
 3
 4use Symfony\Component\Validator\Constraints\Email;
 5use Symfony\Component\Validator\Constraints\NotBlank;
 6use Symfony\Component\Validator\Constraints\MinLength;
 7use Symfony\Component\Validator\Constraints\MaxLength;
 8use Symfony\Component\Validator\Constraints\Collection;
 9use Symfony\Component\Validator\Constraints\True;
10use Symfony\Component\Validator\Constraints\NotNull;
11use Symfony\Component\Validator\Constraints\Callback;
12use Symfony\Component\Validator\ExecutionContext;
13
14   /**
15     * Validates Register Data passed as an array to be reused
16     * 
17     * @throws \Symfony\Component\HttpKernel\Exception\HttpException
18     * @param array $registerData
19     */
20    protected function validateRegisterData(array $registerData)
21    {
22        /**
23          * Imagine that we get an array with these keys: email, pass, postcode and termandco (which is actually Ulabox register data needed)
24          */
25        $collectionConstraint = new Collection(array(
26            'email' => array(
27                        new NotBlank(),
28                        new Email(),
29                        new Callback(array('methods' => array(
30                                        array($this, 'checkMailNotRegistered')
31                                     ))),
32                        ),
33            'pass'  => array(
34                         new NotBlank(),
35                         new MinLength(array('limit' => 8)),
36                         new MaxLength(array('limit' => 22)),
37                        ),
38            'postcode' => array(
39                         new NotBlank(),
40                         new MinLength(array('limit' => 5)),
41                         new MaxLength(array('limit' => 5)),
42                         new Callback(array('methods' => array(
43                                         array($this, 'isValidPostalCode')
44                                      ))),
45                         ),
46            'termandcon' => array(
47                         new NotNull(),
48                         new True(),
49                        ),
50        ));
51        
52        /**
53         * validateValue expects either a scalar value and its constraint or an array and a constraint Collection (which actually extends Constraint)
54         */
55        $errors = $this->container->get('validator')->validateValue($registerData, $collectionConstraint);
56
57        /**
58         * To use symfony2 default validation errors, we must call it this way...
59         * Count is used as this is not an array but a ConstraintViolationList
60         */
61        if (count($errors) !== 0) {
62            throw new HttpException(400, $errors[0]->getPropertyPath() . ':' . $this->container->get('translator')->trans($errors[0]->getMessage(), array(), 'validators'));
63        }
64    }
65
66    /**
67     * Validates PostalCode against Postalcode Entity
68     *
69     * @param string $postalcode
70     * @param \Symfony\Component\Validator\ExecutionContext $context
71     */
72    public function isValidPostalCode($postalcode, ExecutionContext $context)
73    {
74        if (!$this->container->get('logistics')->checkPostalcode($postalcode)) {
75            $context->addViolation('customer.register.invalidpostalcode', array(), null);
76        }
77    }
78
79    /**
80     * Checks that e-mail is not already registered
81     * 
82     * @param string $email
83     * @param \Symfony\Component\Validator\ExecutionContext $context
84     */
85    public function checkMailNotRegistered($email, ExecutionContext $context)
86    {
87        $em = $this->container->get('doctrine')->getEntityManager();
88        $customer = $em->getRepository('UlaboxCoreBundle:Customer')->findOneBy(array('email' => $email));
89        if ($customer instanceof Customer) {
90            $context->addViolation('customer.register.mailregistered', array(), null);
91        }
92    }

This time we are using directly the Constraints and building up a Collection of constraints that the array must satisfy. As you can see, this syntax make it easy to understand what the code is doing, even if you’re not a talented programmer!

Note that some of them are quite simple (string lengths, email format, but there are some interesting ones with the Callback constraint. In this case, we also validate that an email cannot be registered twice and that the postalcode is a real one and not just one that fits the 5 length characters. Of course, for a fully working example, repositories for that should be created but I think that again the code is pretty self-explanatory.

Also note that again, messages are “codified” to use translations in yml, but this time we are not using a messages.LANG.yml file but a validators.LANG.yml file to store the messages. This is because Symfony2 has most of the common messages already coded in many languages so that we don’t have to translate the “This is not a valid e-mail” message and stuff like this. And these translations are merged with our own ones.

This is achieved with $this->container->get(‘translator’)->trans($errors[0]->getMessage(), array(), ‘validators’));

Hope you liked this pieces of code that I think can be useful for inspiration and understand a little bit more how Symfony2 validation (and translation) works!