How to validate unique entities in an entity collection in symfony2

I've created a custom constraint/validator for this.

It validates a form collection using the "All" assertion, and takes an optional parameter : the property path of the property to check the entity equality.

(it's for Symfony 2.1, to adapt it to Symfony 2.0 check the end of the answer) :

For more information on creating custom validation constraints, check The Cookbook

The constraint :

#src/Acme/DemoBundle/Validator/constraint/UniqueInCollection.php
<?php

namespace Acme\DemoBundle\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

/**
* @Annotation
*/
class UniqueInCollection extends Constraint
{
    public $message = 'The error message (with %parameters%)';
    // The property path used to check wether objects are equal
    // If none is specified, it will check that objects are equal
    public $propertyPath = null;
}

And the validator :

#src/Acme/DemoBundle/Validator/constraint/UniqueInCollectionValidator.php
<?php

namespace Acme\DemoBundle\Validator\Constraint;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Form\Util\PropertyPath;

class UniqueInCollectionValidator extends ConstraintValidator
{

    // We keep an array with the previously checked values of the collection
    private $collectionValues = array();

    // validate is new in Symfony 2.1, in Symfony 2.0 use "isValid" (see below)
    public function validate($value, Constraint $constraint)
    {
        // Apply the property path if specified
        if($constraint->propertyPath){
            $propertyPath = new PropertyPath($constraint->propertyPath);
            $value = $propertyPath->getValue($value);
        }

        // Check that the value is not in the array
        if(in_array($value, $this->collectionValues))
            $this->context->addViolation($constraint->message, array());

        // Add the value in the array for next items validation
        $this->collectionValues[] = $value;
    }
}

In your case, you would use it like this :

use Acme\DemoBundle\Validator\Constraints as AcmeAssert;

// ...

/**
 * @ORM\OneToMany(targetEntity="Discount", mappedBy="client", cascade={"persist"}, orphanRemoval="true")
 * @Assert\All(constraints={
 *     @AcmeAssert\UniqueInCollection(propertyPath ="product")
 * })
 */

For Symfony 2.0, change the validate function by :

public function isValid($value, Constraint $constraint)
{
        $valid = true;

        if($constraint->propertyPath){
            $propertyPath = new PropertyPath($constraint->propertyPath);
            $value = $propertyPath->getValue($value);
        }

        if(in_array($value, $this->collectionValues)){
            $valid = false;
            $this->setMessage($constraint->message, array('%string%' => $value));
        }

        $this->collectionValues[] = $value;

        return $valid

}

For Symfony 4.3(only tested version) you can use my custom validator. Prefered way of usage is as annotaion on validated collection:

use App\Validator\Constraints as App;

...

/**
 * @ORM\OneToMany
 *
 * @App\UniqueProperty(
 *     propertyPath="entityProperty"
 * )
 */
private $entities;

Difference between Julien and my solution is, that my Constraint is defined on validated Collection instead on element of Collection itself.

Constraint:

#src/Validator/Constraints/UniqueProperty.php
<?php


namespace App\Validator\Constraints;


use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class UniqueProperty extends Constraint
{
    public $message = 'This collection should contain only elements with uniqe value.';
    public $propertyPath;

    public function validatedBy()
    {
        return UniquePropertyValidator::class;
    }
}

Validator:

#src/Validator/Constraints/UniquePropertyValidator.php
<?php

namespace App\Validator\Constraints;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class UniquePropertyValidator extends ConstraintValidator
{
    /**
     * @var \Symfony\Component\PropertyAccess\PropertyAccessor
     */
    private $propertyAccessor;

    public function __construct()
    {
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
    }

    /**
     * @param mixed $value
     * @param Constraint $constraint
     * @throws \Exception
     */
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof UniqueProperty) {
            throw new UnexpectedTypeException($constraint, UniqueProperty::class);
        }

        if (null === $value) {
            return;
        }

        if (!\is_array($value) && !$value instanceof \IteratorAggregate) {
            throw new UnexpectedValueException($value, 'array|IteratorAggregate');
        }

        if ($constraint->propertyPath === null) {
            throw new \Exception('Option propertyPath can not be null');
        }

        $propertyValues = [];
        foreach ($value as $key => $element) {
            $propertyValue = $this->propertyAccessor->getValue($element, $constraint->propertyPath);
            if (in_array($propertyValue, $propertyValues, true)) {
                $this->context->buildViolation($constraint->message)
                    ->atPath(sprintf('[%s]', $key))
                    ->addViolation();
            }

            $propertyValues[] = $propertyValue;
        }
    }
}

Here is a version working with multiple fields just like UniqueEntity does. Validation fails if multiple objects have same values.

Usage:

/**
* ....
* @App\UniqueInCollection(fields={"name", "email"})
*/
private $contacts;
//Validation fails if multiple contacts have same name AND email

The constraint class ...

<?php
namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class UniqueInCollection extends Constraint
{
    public $message = 'Entry is duplicated.';
    public $fields;

    public function validatedBy()
    {
        return UniqueInCollectionValidator::class;
    }
}

The validator itself ....

<?php

namespace App\Validator\Constraints;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class UniqueInCollectionValidator extends ConstraintValidator
{
    /**
     * @var \Symfony\Component\PropertyAccess\PropertyAccessor
     */
    private $propertyAccessor;

    public function __construct()
    {
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
    }

    /**
     * @param mixed $collection
     * @param Constraint $constraint
     * @throws \Exception
     */
    public function validate($collection, Constraint $constraint)
    {
        if (!$constraint instanceof UniqueInCollection) {
            throw new UnexpectedTypeException($constraint, UniqueInCollection::class);
        }

        if (null === $collection) {
            return;
        }

        if (!\is_array($collection) && !$collection instanceof \IteratorAggregate) {
            throw new UnexpectedValueException($collection, 'array|IteratorAggregate');
        }

        if ($constraint->fields === null) {
            throw new \Exception('Option propertyPath can not be null');
        }

        if(is_array($constraint->fields)) $fields = $constraint->fields;
        else $fields = [$constraint->fields];


        $propertyValues = [];
        foreach ($collection as $key => $element) {
            $propertyValue = [];
            foreach ($fields as $field) {
                $propertyValue[] = $this->propertyAccessor->getValue($element, $field);
            }


            if (in_array($propertyValue, $propertyValues, true)) {

                $this->context->buildViolation($constraint->message)
                    ->atPath(sprintf('[%s]', $key))
                    ->addViolation();
            }

            $propertyValues[] = $propertyValue;
        }

    }
}

I can't manage to make the previous answer works on symfony 2.6. Because of the following code on l. 852 of RecursiveContextualValidator, it only goes once on the validate method when 2 items are equals.

if ($context->isConstraintValidated($cacheKey, $constraintHash)) {
    continue; 
} 

So, here is what I've done to deals with the original issue :

On the Entity :

* @AcmeAssert\UniqueInCollection(propertyPath ="product")

Instead of

* @Assert\All(constraints={
*     @AcmeAssert\UniqueInCollection(propertyPath ="product")
* })

On the validator :

public function validate($collection, Constraint $constraint){

    $propertyAccessor = PropertyAccess::getPropertyAccessor(); 

    $previousValues = array();
    foreach($collection as $collectionItem){
        $value = $propertyAccessor->getValue($collectionItem, $constraint->propertyPath);
        $previousSimilarValuesNumber = count(array_keys($previousValues,$value));
        if($previousSimilarValuesNumber == 1){
            $this->context->addViolation($constraint->message, array('%email%' => $value));
        }
        $previousValues[] = $value;
    }

}

Instead of :

public function isValid($value, Constraint $constraint)
{
    $valid = true;

    if($constraint->propertyPath){
        $propertyAccessor = PropertyAccess::getPropertyAccessor(); 
        $value = $propertyAccessor->getValue($value, $constraint->propertyPath);
    }

    if(in_array($value, $this->collectionValues)){
        $valid = false;
        $this->setMessage($constraint->message, array('%string%' => $value));
    }

    $this->collectionValues[] = $value;

    return $valid

}