[Symfony] On using the UniqueEntity validator without annotation

Published on 2019-07-04 • Modified on 2019-07-04

When having form for entities, it is common to have unique constraints, for example on the username or the email of a user. I am not a big fan of using annotations for validation so here is how to add this kind of validation when having a form type for an entity. The trick here is to add the constraint at the form level and not a the field level. In this example we will create a fake type for the Article entity of this website, then we will check that the unique constraint is working for the slug as it must be unique for each article. As you can see there is no database id in the URL to identify the snippets or articles: the slug is the main identifier.


<?php

declare(strict_types=1);

// src/Form/ArticleType.php

namespace App\Form;

use App\Entity\Article;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;

/**
 * Fake article form for snippet30.
 */
final class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $article = $builder->getData();
        if (!$article instanceof Article) {
            throw new \RuntimeException('Invalid entity.');
        }

        if ($article->isArticle() || $article->isSnippet()) {
            $builder->add('slug', TextType::class, ['constraints' => [new NotBlank()]]);
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'csrf_protection' => false,
            'data_class' => Article::class,
            'constraints' => [
                new UniqueEntity(['fields' => ['slug']]),
            ],
        ]);
    }
}
Bonus, the snippet to run this code: 🎉
<?php

declare(strict_types=1);

namespace App\Controller\Snippet;

use App\Entity\Article;
use App\Form\ArticleType;
use Symfony\Component\Form\FormFactoryInterface;

/**
 * I am using a PHP trait to isolate each snippet in a file.
 * This code should be called from a Symfony controller extending AbstractController (as of Symfony 4.2)
 * or Symfony\Bundle\FrameworkBundle\Controller\Controller (Symfony <= 4.1).
 * Services are injected in the main controller constructor.
 *
 * @property FormFactoryInterface $formFactory
 */
trait Snippet30Trait
{
    /**
     * Test the validation with a set of values.
     */
    public function snippet30(): void
    {
        $slugs = [
            // This is the reference slug of this snippet (in english) so the validation must fail
            'on-using-the-uniqueentity-validator-without-annotation',

            // These ones should be OK
            'new-unique-slug',
            'other-new-unique-slug',
        ];

        // Manually submit values to the form. Note that the form creation is in the loop because a form can only be submitted once!
        foreach ($slugs as $slug) {
            $form = $this->formFactory->create(ArticleType::class, new Article());
            $form->submit(compact('slug'));
            if ($form->isValid()) {
                echo sprintf('✅ Form is valid! slug: "%s"', $slug);
            } else {
                echo sprintf('❌ Form is not valid: Error: "%s" (slug: "%s")', trim((string) $form->getErrors(true)), $slug);
            }
            echo PHP_EOL;
        }

        // That's it! 😁
    }
}

 Run this snippet  ≪ this.showUnitTest ? this.trans.hide_unit_test : this.trans.show_unit_test ≫  More on Stackoverflow   Read the doc  Random snippet

  Work with me!

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Controller\Snippets;

use App\Entity\Article;
use App\Form\ArticleType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;

/**
 * @see Snippet30Trait
 */
final class Snippet30Test extends KernelTestCase
{
    private FormFactoryInterface $formFactory;

    protected function setUp(): void
    {
        $this->formFactory = self::getContainer()->get('form.factory');
    }

    /**
     * @return iterable<int, array{0: string, 1: bool}>
     */
    public function provide(): iterable
    {
        yield ['on-using-the-uniqueentity-validator-without-annotation', false];
        yield ['new-unique-slug', true];
        yield ['other-new-unique-slug', true];
    }

    /**
     * @see Snippet30Trait::snippet30
     *
     * @dataProvider provide
     */
    public function testSnippet30(string $slug, bool $isValid): void
    {
        $article = new Article();
        $article->setAuthor('COil');
        $form = $this->formFactory->create(ArticleType::class, $article);
        $form->submit(compact('slug'));
        self::assertSame($form->isValid(), $isValid);
    }
}