Data Driven Design icon

ZF2 Form Collection Validation - Unique Elements

Nov 01, 2013

I love ZF2 collections. They are extremely good at a number of things but one place they fall flat is in complex validation. As you start to get into more complex validation issues such as making sure that specific elements in the collection are unique within the collection or validation the count of the collection you will find that there is really not a clean way to go about doing it. You may look into the call back input filter and passing in the context to get the information but you will find that the context only contains the current row's elements within the collection. So what do you do? The next couple of posts that I do will cover advanced validation of ZF2 collections, starting with this one, validation that a given list of elements are unique within the collection. One caveat, I am not sure if this is the best way to do this, but there is no information out there on how to go about this and after many hours playing with it, this will work. If you know a better way please post in the comments below as the main purpose of this post is to get a conversation going about how to handle these use cases. Alright, grab a coffee and get ready, here we go. I am assuming that if you are reading this you already know how to create a collection. If not read the ZF2 Collection Quickstart and then come back here. We will build off of what is presented in the quickstart so save your code to work with. Okay we are using the quickstart code as a starting point. First setup your entities as shown. Application\Entity\Brand

namespace Application\Entity
  
class Brand
{
    /**
     * @var string
     \*/
    protected $name;
  
    /**
     * @var string
     \*/
    protected $url;
  
    /**
     * @param string $name
     * @return Brand
     \*/
    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }
  
    /**
     * @return string
     \*/
    public function getName()
    {
        return $this->name;
    }
  
    /**
     * @param string $url
     * @return Brand
     \*/
    public function setUrl($url)
    {
        $this->url = $url;
        return $this;
    }
  
    /**
     * @return string
     \*/
    public function getUrl()
    {
        return $this->url;
    }
}

Application\Entity\Category

namespace Application\Entity
 
class Category
{
    /**
     * @var string
     \*/
    protected $name;
 
    /**
     * @param string $name
     * @return Category
     \*/
    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }
 
    /**
     * @return string
     \*/
    public function getName()
    {
        return $this->name;
    }
}

Application\Entity\Product

namespace Application\Entity;
 
class Product
{
    /**
     * @var string
     \*/
    protected $name;
 
    /**
     * @var int
     \*/
    protected $price;
 
    /**
     * @var Brand
     \*/
    protected $brand;
 
    /**
     * @var array
     \*/
    protected $categories;
 
    /**
     * @param string $name
     * @return Product
     \*/
    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }
 
    /**
     * @return string
     \*/
    public function getName()
    {
        return $this->name;
    }
 
    /**
     * @param int $price
     * @return Product
     \*/
    public function setPrice($price)
    {
        $this->price = $price;
        return $this;
    }
 
    /**
     * @return int
     \*/
    public function getPrice()
    {
        return $this->price;
    }
 
    /**
     * @param Brand $brand
     * @return Product
     \*/
    public function setBrand(Brand $brand)
    {
        $this->brand = $brand;
        return $this;
    }
 
    /**
     * @return Brand
     \*/
    public function getBrand()
    {
        return $this->brand;
    }
 
    /**
     * @param array $categories
     * @return Product
     \*/
    public function setCategories(array $categories)
    {
        $this->categories = $categories;
        return $this;
    }
 
    /**
     * @return array
     \*/
    public function getCategories()
    {
        return $this->categories;
    }
}

Next setup the fieldsets Application\Form\BrandFieldset

namespace Application\Form;
 
use Application\Entity\Brand;
use Zend\Form\Fieldset;
use Zend\InputFilter\InputFilterProviderInterface;
use Zend\Stdlib\Hydrator\ClassMethods as ClassMethodsHydrator;
 
class BrandFieldset extends Fieldset implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('brand');
        $this->setHydrator(new ClassMethodsHydrator(false))
            ->setObject(new Brand());
 
        $this->add(array(
            'name' => 'name',
            'options' => array(
                'label' => 'Name of the brand'
            ),
            'attributes' => array(
                'required' => 'required'
            )
        ));
 
        $this->add(array(
            'name' => 'url',
            'type' => 'Zend\Form\Element\Url',
            'options' => array(
                'label' => 'Website of the brand'
            ),
            'attributes' => array(
                'required' => 'required'
            )
        ));
    }
 
    /**
     * @return array
     \*/
    public function getInputFilterSpecification()
    {
        return array(
            'name' => array(
                'required' => true,
            )
        );
    }
}

Application\Form\CategoryFieldset

namespace Application\Form;
  
use Application\Entity\Category;
use Zend\Form\Fieldset;
use Zend\InputFilter\InputFilterProviderInterface;
use Zend\Stdlib\Hydrator\ClassMethods as ClassMethodsHydrator;
  
class CategoryFieldset extends Fieldset implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('category');
        $this->setHydrator(new ClassMethodsHydrator(false))
             ->setObject(new Category());
  
        $this->setLabel('Category');
  
        $this->add(array(
            'name' => 'name',
            'options' => array(
                'label' => 'Name of the category'
            ),
            'attributes' => array(
                'required' => 'required'
            )
        ));
    }
  
    /**
     * @return array
     \*/
    public function getInputFilterSpecification()
    {
        return array(
            'name' => array(
                'required' => true,
            )
        );
    }
}

Application\Form\ProductFieldset

namespace Application\Form;
  
use Application\Entity\Product;
use Zend\Form\Fieldset;
use Zend\InputFilter\InputFilterProviderInterface;
use Zend\Stdlib\Hydrator\ClassMethods as ClassMethodsHydrator;
  
class ProductFieldset extends Fieldset implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('product');
        $this->setHydrator(new ClassMethodsHydrator(false))
             ->setObject(new Product());
  
        $this->add(array(
            'name' => 'name',
            'options' => array(
                'label' => 'Name of the product'
            ),
            'attributes' => array(
                'required' => 'required'
            )
        ));
  
        $this->add(array(
            'name' => 'price',
            'options' => array(
                'label' => 'Price of the product'
            ),
            'attributes' => array(
                'required' => 'required'
            )
        ));
  
        $this->add(array(
            'type' => 'Application\Form\BrandFieldset',
            'name' => 'brand',
            'options' => array(
                'label' => 'Brand of the product'
            )
        ));
  
        $this->add(array(
            'type' => 'Zend\Form\Element\Collection',
            'name' => 'categories',
            'options' => array(
                'label' => 'Please choose categories for this product',
                'count' => 2,
                'should_create_template' => true,
                'allow_add' => true,
                'target_element' => array(
                    'type' => 'Application\Form\CategoryFieldset'
                )
            )
        ));
    }
  
    /**
     * Should return an array specification compatible with
     * {@link Zend\InputFilter\Factory::createInputFilter()}.
     *
     * @return array
     \*/
    public function getInputFilterSpecification()
    {
        return array(
            'name' => array(
                'required' => true,
            ),
            'price' => array(
                'required' => true,
                'validators' => array(
                    array(
                        'name' => 'Float'
                    )
                )
            )
        );
    }
}

I really don't like having the input filter specified in the form so lets edit the fieldsets so that the input filter is created using a factory. Application\Form\BrandFieldsetFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
  
class BrandFieldsetFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add($factory->createInput(array(
          'name' => 'name',
          'required' => true,
        )));     
    }
}

Application\Form\CategoryFieldsetFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
  
class CategoryFieldsetFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add($factory->createInput(array(
          'name' => 'name',
          'required' => true,
        )));     
    }
}

Application\Form\ProductFieldsetFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
  
class ProductFieldsetFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add($factory->createInput(array(
          'name' => 'name',
          'required' => true,
        )));  
         
        $this->add($factory->createInput(array(
          'name' => 'price',
          'required' => true,
          'validators' => array(
            array('name' => 'Float')
          )
        ))); 
    }
}

If you really want this to be clean centralize your form input filter definitions in a separate Form module so that you can reuse the definitions site wide instead of specifying them all over the place. I will show how to do this in a later post. Now that we have the input filter specified this way we need to do a couple of things. Notice that the ProductFieldset is pulling in the CategoryFieldset and and BrandFieldset. The BrandFieldset is being used as a standard fieldset but the the CategoriesFielset is being used as a collection. We need to setup our input filters to account for this. The ProductFieldset will pull in the ProductFieldsetFilter and the ProductFieldsetFilter will pull in the child fieldset filters. It makes more sense when you see it in code. Lets set it up. Application\Form\ProductFieldsetFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
use Zend\InputFilter\CollectionInputFilter;
  
class ProductFieldsetFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add($factory->createInput(array(
          'name' => 'name',
          'required' => true,
        )));  
         
        $this->add($factory->createInput(array(
          'name' => 'price',
          'required' => true,
          'validators' => array(
            array('name' => 'Float')
          )
        ))); 
         
        // Add the brand fieldset filter
        $this->add(new BrandFieldsetFilter(), 'brand');
         
        // Add the categories collection filter
        $categoriesFilter = new CollectionInputFilter();
        $categoriesFilter->setInputFilter(new CategoryFieldsetFilter());
        $this->add($categoriesFilter, 'categories');
    }
}

Notice that we are using a collection input filter. What this does is take each row added to a collection and pass it to the input filter specified. This allows you to properly validation 0-n rows of data and receive validation messages for each row accordingly. Now that we have this filters built lets setup the main form. Application\Form\CreateProduct

namespace Application\Form;
  
use Zend\Form\Form;
use Zend\InputFilter\InputFilter;
use Zend\Stdlib\Hydrator\ClassMethods as ClassMethodsHydrator;
  
class CreateProduct extends Form
{
    public function __construct()
    {
        parent::__construct('create_product');
  
        $this->setAttribute('method', 'post')
             ->setHydrator(new ClassMethodsHydrator(false))
             ->setInputFilter(new InputFilter());
  
        $this->add(array(
            'type' => 'Application\Form\ProductFieldset',
            'options' => array(
                'use_as_base_fieldset' => true
            )
        ));
  
        $this->add(array(
            'type' => 'Zend\Form\Element\Csrf',
            'name' => 'csrf'
        ));
  
        $this->add(array(
            'name' => 'submit',
            'attributes' => array(
                'type' => 'submit',
                'value' => 'Send'
            )
        ));
    }
}

Now create the input filter for it Application\Form\CreateProductFormFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
  
class CreateProductFormFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add(new ProductFieldsetFilter(), 'product');     
    }
}

Finally lets add the input filter to the create product form Application\Form\CreateProduct

namespace Application\Form;
  
use Zend\Form\Form;
use Zend\InputFilter\InputFilter;
use Zend\Stdlib\Hydrator\ClassMethods as ClassMethodsHydrator;
  
class CreateProduct extends Form
{
    public function __construct()
    {
        parent::__construct('create_product');
  
        $this->setAttribute('method', 'post')
             ->setHydrator(new ClassMethodsHydrator(false))
             ->setInputFilter(new CreateProductFormFilter());
  
        $this->add(array(
            'type' => 'Application\Form\ProductFieldset',
            'name' => 'product',
            'options' => array(
                'use_as_base_fieldset' => true
            )
        ));
  
        $this->add(array(
            'type' => 'Zend\Form\Element\Csrf',
            'name' => 'csrf'
        ));
  
        $this->add(array(
            'name' => 'submit',
            'attributes' => array(
                'type' => 'submit',
                'value' => 'Send'
            )
        ));
    }
}

With all of this now in place you should have the example working just like in the quickstart. You should be able to enter product and brand information and add the product to n number of categories. But now lets say you want to make sure that the product is only added to each category once. If you want to validate that each entry in the collection is unique, how would you go about it? The answer is that we need to extend the collectionInputFilter to allow us to specify n number of elements in each collection row that will be unique. They will then upon submission be checked against each other and any duplicates will cause a validation error and message to be displayed accordingly. To start we need to create our own collectionInputFilter and extend ZF2's version. In the real world you would do this in its own module but for brevity sake we will just add it to our existing Application module. So lets go ahead and set it up. Application\InputFilter\CollectionInputFilter

namespace Application\InputFilter;
  
use Traversable;
use Zend\InputFilter\CollectionInputFilter as ZendCollectionInputFilter;
  
class CollectionInputFilter extends ZendCollectionInputFilter
{    
    protected $uniqueFields;
     
    protected $message;
     
    const UNIQUE_MESSAGE = 'Each item must be unique within the collection';
  
    /**
     * @return the $message
     */
    public function getMessage()
    {
        return $this->message;
    }
  
    /**
     * @param field_type $message
     */
    public function setMessage($message)
    {
        $this->message = $message;
    }
     
    /**
     * @return the $uniqueFields
     */
    public function getUniqueFields()
    {
        return $this->uniqueFields;
    }
  
 /**
     * @param multitype:string  $uniqueFields
     */
    public function setUniqueFields($uniqueFields)
    {
        $this->uniqueFields = $uniqueFields;
    }
     
    public function isValid()
    {
        $valid = parent::isValid();
  
        // Check that any fields set to unique are unique
        if($this->uniqueFields)
        {
            // for each of the unique fields specified spin through the collection rows and grab the values of the elements specified as unique.
            foreach($this->uniqueFields as $k => $elementName)
            {
                $validationValues = array();
                foreach($this->collectionData as $rowKey => $rowValue)
                {
                    // Check if the row has a deleted element and if it is set to 1. If it is don't validate this row.
                    if(array_key_exists('deleted', $rowValue) && $rowValue['deleted'] == 1) continue;
                    
                    $validationValues[] = $rowValue[$elementName];
                }
                 
                // Get only the unique values and then check if the count of unique values differs from the total count
                $uniqueValues = array_unique($validationValues);
                if(count($uniqueValues) < count($validationValues))
                {            
                    // The counts didn't match so now grab the row keys where the duplicate values were and set the element message to the element on that row
                    $duplicates = array_keys(array_diff_key($validationValues, $uniqueValues));
                    $valid = false;
                    $message = ($this->getMessage()) ? $this->getMessage() : $this::UNIQUE_MESSAGE;
                    foreach($duplicates as $duplicate)
                    {
                        $this->collectionInvalidInputs[$duplicate][$elementName] = array('unique' => $message);
                    }
                }
            }
             
            return $valid;
        }
    }
  
    public function getMessages()
    {
        $messages = array();
        if (is_array($this->getInvalidInput()) || $this->getInvalidInput() instanceof Traversable) {
            foreach ($this->getInvalidInput() as $key => $inputs) {
                foreach ($inputs as $name => $input) {
                    if(!is_string($input) && !is_array($input))
                    {
                        $messages[$key][$name] = $input->getMessages();                                                
                        continue;
                    }         
                    $messages[$key][$name] = $input;
                }
            }
        }
        return $messages;
    }
}

Okay there is a lot going on in this file. First we are setting up a protected property uniqueFields. This will contain our array of fields to validate. Next we setup a getter and setter for this property so we can access it from the input filter specification file later. Next we set up a constant which will contain our default validation failure message that will display under the form elements. We also setup another protected property called message and the corresponding getter and setter. These will allow us to override the default message if we want to. We are overriding the getMessages() method in order to allow us to pass in validation messages that will be set to the form elements without the need to pass a full input filter. If we pass either a string or an array it will simply set the message to the element without trying to call methods that are specific to an inputFilter/input. Last but not least is the isValid() method. There is a lot going on here. Basically if an array of elements is set to the uniqueFields property this will spin through all of the collection data and make sure they are all unique. If they are it will return true. If they aren't it will set the validation message to the invalid elements in each collection row by key and return false; Now lets look at how to use this. Lets setup our categories collection to validate that the category name is unique within the collection. Application\Form\CategoryFieldsetFilter

namespace Application\Form;
  
use Zend\InputFilter\InputFilter;
use Zend\InputFilter\Factory as InputFactory;
use Application\InputFilter\CollectionInputFilter;
  
class ProductFieldsetFilter extends InputFilter
{
    public function __construct()
    {
        $factory = new InputFactory();
         
        $this->add($factory->createInput(array(
          'name' => 'name',
          'required' => true,
        )));  
         
        $this->add($factory->createInput(array(
          'name' => 'price',
          'required' => true,
          'validators' => array(
            array('name' => 'Float')
          )
        ))); 
         
        // Add the brand fieldset filter
        $this->add(new BrandFieldsetFilter(), 'brand');
         
        // Add the categories collection filter
        $categoriesFilter = new CollectionInputFilter();
        $categoriesFilter->setInputFilter(new CategoryFieldsetFilter());
        $categoriesFilter->setUniqueFields(array('name'));
        $categoriesFilter->setMessage('Each category must be unique.');
        $this->add($categoriesFilter, 'categories');
    }
}

Notice that we have changed the statement at the to use our custom collectionInputFilter instead of the ZF2 standard filter. We have also setup an array of element names within the collection that should be unique and set a custom validation message. Now if you try to submit the form with duplicate categories you should get back your custom validation messages. I hope this helped someone out since validating collections can be a nightmare to figure out. Next up will be a post on how to validate the count falls within a min - max range specified. Happy coding.

Get In Touch.
Contact