Embedded Form Validation with @Valid

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

We've got more work to do! So head back to /admin/genus. Leave the "Years Studied" field empty for one of the GenusScientist forms and submit.

Explosion!

UPDATE genus_scientist SET years_studied = NULL

This field is not allowed to be null in the database. That's on purpose... but we're missing validation! Lame!

But no problem, right? We'll just go into the Genus class, copy the as Assert use statement, paste it into GenusScientist and then - above yearsStudied - add @Assert\NotBlank:

... lines 1 - 5
use Symfony\Component\Validator\Constraints as Assert;
... lines 7 - 11
class GenusScientist
{
... lines 14 - 32
/**
... line 34
* @Assert\NotBlank()
*/
private $yearsStudied;
... lines 38 - 72
}

Cool! Now, the yearsStudied field will be required.

Go try it out: refresh the page, empty out the field again, submit and... What!? It still doesn't work!?

@Valid for a Good Time

It's as if Symfony doesn't see the new validation constraint! Why? Here's the deal: our form is bound to a Genus object:

... lines 1 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\OneToMany(
* targetEntity="GenusScientist",
* mappedBy="genus",
* fetch="EXTRA_LAZY",
* orphanRemoval=true,
* cascade={"persist"}
* )
*/
private $genusScientists;
... lines 82 - 208
}

That's the top-level object that we're modifying. And by default, Symfony reads all of the validation annotations from the top-level class... only. When it sees an embedded object, or an array of embedded objects, like the genusScientists property, it does not go deeper and read the annotations from the GenusScientist class. In other words, Symfony only validates the top-level object.

Double-lame! What the heck Symfony?

No no, it's cool, it's on purpose. You can easily activate embedded validation by adding a unique annotation above that property: @Assert\Valid:

... lines 1 - 6
use Symfony\Component\Validator\Constraints as Assert;
... lines 8 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\OneToMany(
* targetEntity="GenusScientist",
* mappedBy="genus",
* fetch="EXTRA_LAZY",
* orphanRemoval=true,
* cascade={"persist"}
* )
* @Assert\Valid()
*/
private $genusScientists;
... lines 83 - 209
}

That's it! Now refresh. Validation achieved!

Preventing Duplicate GenusScientist

But there's one other problem. I know, I always have bad news. Set one of the users to aquanaut3. Well, that's actually a duplicate of this one... and it doesn't really make sense to have the same user listed as two different scientists. Whatever! Save right now: it's all good: aquanaut3 and aquanaut3. I want validation to prevent this!

No problem! In GenusScientist add a new annotation above the class: yep, a rare constraint that goes above the class instead of a property: @UniqueEntity. Make sure to auto-complete that to get a special use statement for this:

... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
... lines 13 - 14
* )
*/
class GenusScientist
{
... lines 19 - 77
}

This takes a few options, like fields={"genus", "user"}:

... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
... line 14
* )
*/
class GenusScientist
{
... lines 19 - 77
}

This says:

Don't allow there to be two records in the database that have the same genus and user.

Add a nice message, like:

This user is already studying this genus.

... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
* message="This user is already studying this genus"
* )
*/
class GenusScientist
{
... lines 19 - 77
}

Great!

Ok, try this bad boy! We already have duplicates, so just hit save. Validation error achieved! But... huh... there are two errors and they're listed at the top of the form, instead of next to the offending fields.

First, ignore the two messages - that's simply because we allowed our app to get into an invalid state and then added validation. That confused Symfony. Sorry! You'll normally only see one message.

But, having the error message way up on top... that sucks! The reason why this happens is honestly a little bit complex: it has to do with the CollectionType and something called error_bubbling. The more important thing is the fix: after the message option, add another called errorPath set to user:

... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
* message="This user is already studying this genus",
* errorPath="user"
* )
*/
class GenusScientist
{
... lines 20 - 78
}

In a non embedded form, the validation error message from UniqueEntity normally shows at the top of the form... which makes a lot of sense in that situation. But when you add this option, it says:

Yo! When this error occurs, I want you to attach it to the user field.

So refresh! Error is in place! And actually, let me get us out of the invalid state: I want to reset my database to not have any duplicates to start. Now if we change one back to a duplicate, it looks great... and we don't have two errors anymore.

Fixing CollectionType Validation Bug

There is one small bug left with our validation! And it's tricky! To see it: add 2 new scientists, immediately remove the first, leave the yearsStudied field blank, and then submit. We should see a validation error appearing below the yearsStudied field. Instead, it appears no the top of the form! This is actually caused by a bug in Symfony, but we can fix it easily! The following code block shows the fix and has more details:

... lines 1 - 14
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
... lines 17 - 20
class GenusFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 25 - 56
$builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, 'onPreSubmit'));
}
... lines 59 - 66
/**
* This fixes a validation issue with the Collection. Suppose
* the following situation:
*
* A) Edit a Genus
* B) Add 2 new scientists - don't submit & leave all fields blank
* C) Delete the FIRST scientist
* D) Submit the form
*
* The one new scientist has a validation error, because
* the yearsStudied field was left blank. But, this error
* shows at the *top* of the form, not attached to the form.
* The reason is that, on submit, addGenusScientist() is
* called, and the new scientist is added to the next available
* index (so, if the Genus previously had 2 scientists, the
* new GenusScientist is added to the "2" index). However,
* in the HTML before the form was submitted, the index used
* in the name attribute of the fields for the new scientist
* was *3*: 0 & 1 were used for the existing scientists and 2 was
* used for the first genus scientist form that you added
* (and then later deleted). This mis-match confuses the validator,
* which thinks there is an error on genusScientists[2].yearsStudied,
* and fails to map that to the genusScientists[3].yearsStudied
* field.
*
* Phew! It's a big pain :). Below, we fix it! On submit,
* we simply re-index the submitted data before it's bound
* to the form. The submitted genusScientists data, which
* previously had index 0, 1 and 3, will now have indexes
* 0, 1 and 2. And these indexes will match the indexes
* that they have on the Genus.genusScientists property.
*
* @param FormEvent $event
*/
public function onPreSubmit(FormEvent $event)
{
$data = $event->getData();
$data['genusScientists'] = array_values($data['genusScientists']);
$event->setData($data);
}
}

Leave a comment!

This course is built on Symfony 3, but most of the concepts apply just fine to newer versions of Symfony.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.1.*", // v3.1.4
        "doctrine/orm": "^2.5", // v2.7.2
        "doctrine/doctrine-bundle": "^1.6", // 1.6.4
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // 2.11.1
        "symfony/polyfill-apcu": "^1.0", // v1.2.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "knplabs/knp-markdown-bundle": "^1.4", // 1.4.2
        "doctrine/doctrine-migrations-bundle": "^1.1", // 1.1.1
        "stof/doctrine-extensions-bundle": "^1.2" // v1.2.2
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.7
        "symfony/phpunit-bridge": "^3.0", // v3.1.3
        "nelmio/alice": "^2.1", // 2.1.4
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}