Validation Auto-Mapping

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 $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Head over to /admin/article and log in as an admin user: admin1@thespacebar.com password engage. Use this unchecked admin power to go to /admin/article and click to create a new article.

I love the new "secrets" feature... but what I'm about to show you might be my second favorite new thing. It actually comes from Symfony 4.3 but was improved in 4.4. It's called: validation auto-mapping... and it's one more step towards robots doing my programming for me.

Start by going into templates/article_admin/_form.html.twig:

{{ form_start(articleForm) }}
{{ form_row(articleForm.title, {
label: 'Article title'
}) }}
<div class="row">
<div class="col-sm-9">
{{ form_row(articleForm.imageFile, {
attr: {
'placeholder': 'Select an article image'
}
}) }}
</div>
<div class="col-sm-3">
{% if articleForm.vars.data.imageFilename|default %}
<a href="{{ uploaded_asset(articleForm.vars.data.imagePath) }}" target="_blank">
<img src="{{ articleForm.vars.data.imagePath|imagine_filter('squared_thumbnail_small') }}" height="100">
</a>
{% endif %}
</div>
</div>
{{ form_row(articleForm.author) }}
{{ form_row(articleForm.location, {
attr: {
'data-specific-location-url': path('admin_article_location_select'),
'class': 'js-article-form-location'
}
}) }}
<div class="js-specific-location-target">
{% if articleForm.specificLocationName is defined %}
{{ form_row(articleForm.specificLocationName) }}
{% endif %}
</div>
{{ form_row(articleForm.content) }}
{% if articleForm.publishedAt is defined %}
{{ form_row(articleForm.publishedAt) }}
{% endif %}
<button type="submit" class="btn btn-primary">{{ button_text }}</button>
{{ form_end(articleForm) }}

This is the form that renders the article admin page. To help us play with validation, on the button, add a formnovalidate attribute:

{{ form_start(articleForm) }}
... lines 2 - 39
<button type="submit" class="btn btn-primary" formnovalidate>{{ button_text }}</button>
{{ form_end(articleForm) }}

Thanks to that, after you refresh, HTML5 validation is disabled and we can submit the entire form blank to see... our server-side validation errors. These come from the annotations on the Article class, like @Assert\NotBlank above $title:

... lines 1 - 12
use Symfony\Component\Validator\Constraints as Assert;
... lines 14 - 18
class Article
{
... lines 21 - 29
/**
... line 31
* @Assert\NotBlank(message="Get creative and think of a title!")
*/
private $title;
... lines 35 - 323
}

So it's no surprise that if we remove the @Assert\NotBlank annotation... I'll move it as a comment below the property:

... lines 1 - 18
class Article
{
... lines 21 - 29
/**
* @ORM\Column(type="string", length=255)
*/
private $title;
// @Assert\NotBlank(message="Get creative and think of a title!")
... lines 35 - 323
}

That's as good as deleting it. And then re-submit the blank form... the validation error is gone from that field.

@Assert\EnableAutoMapping

Ready for the magic? Go back to Article and, on top of the class, add @Assert\EnableAutoMapping():

... lines 1 - 12
use Symfony\Component\Validator\Constraints as Assert;
... lines 14 - 15
/**
... line 17
* @Assert\EnableAutoMapping()
*/
class Article
{
... lines 22 - 324
}

As soon as we do that, we can refresh to see... Kidding! We refresh to see... the validation error is back for the title field!

This value should not be null

Yep! A @NotNull constraint was automatically added to the property! How the heck did that work? The system - validation auto-mapping - automatically adds sensible validation constraints based off of your Doctrine metadata. The Doctrine Column annotation has a nullable option and its default value is nullable=false:

... lines 1 - 19
class Article
{
... lines 22 - 30
/**
* @ORM\Column(type="string", length=255)
*/
private $title;
... lines 35 - 324
}

In other words, the title column is required in the database! And so, a constraint is added to make it required on the form.

Auto-mapping can also add constraints based solely on how your code is written... I'll show you an example of that in a few minutes. Oh, and by the way, to get the most out of this feature, make sure you have the symfony/property-info component installed.

composer show symfony/property-info

If that package doesn't come up, install it to allow the feature to get as much info as possible.

Auto-Mapping is Smart

Let's play with this a bit more, like change this to nullable=true:

... lines 1 - 19
class Article
{
... lines 22 - 30
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $title;
... lines 35 - 324
}

This means that the column should now be optional in the database. What happens when we submit the form now? The validation error is gone: the NotNull constraint was not added.

Oh, but it gets even cooler than this. Remove the @ORM\Column entirely - we'll pretend like this property isn't even being saved to the database. I also need to remove this @Gedmo\Slug annotation to avoid an error:

... lines 1 - 19
class Article
{
... lines 22 - 30
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $title;
... lines 35 - 36
/**
... line 38
* @Gedmo\Slug(fields={"title"})
*/
private $slug;
... lines 42 - 324
}

What do you think will happen now? Well think about it: the auto-mapping system won't be able to ask Doctrine if this property is required or not... so my guess is that it won't add any constraints. Try it! Refresh!

Duh, duh, duh! The NotNull validation constraint is back! Whaaaaat? The Doctrine metadata is just one source of info for auto-mapping: it can also look directly at your code. In this case, Symfony looks for a setter method. Search for setTitle():

... lines 1 - 19
class Article
{
... lines 22 - 113
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
... lines 120 - 324
}

Ah yes, the $title argument is type-hinted with string. And because that type-hint does not allow null, it assumes that $title must be required and adds the validation constraint.

Watch this: add a ? before string to make null an allowed value:

... lines 1 - 19
class Article
{
... lines 22 - 109
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
... lines 116 - 320
}

Refresh now and... the error is gone.

Avoiding Duplicate Constraints

Let's put everything back to where it was in the beginning. What I love about this feature is that... it's just so smart! It accuarely reflects what your code is already communicating.

And even if I add back my @Assert\NotBlank annotation:

... lines 1 - 15
/**
... line 17
* @Assert\EnableAutoMapping()
*/
class Article
{
... lines 22 - 30
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank(message="Get creative and think of a title!")
*/
private $title;
/**
* @ORM\Column(type="string", length=100, unique=true)
* @Gedmo\Slug(fields={"title"})
*/
private $slug;
... lines 42 - 113
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
... lines 120 - 324
}

And refresh... check it out. We don't get 2 errors! The auto-mapping system is smart enough to realize that, because I added a NotBlank annotation constraint to this property, it should not also add the NotNull constraint: that would basically be duplication and the user would see two errors. Like I said, it's smart.

Automatic Length Annotation

And it's not all about the NotNull constraint. The length of this column in the database is 255 - that's the default for a string type. Let's type a super-creative title over and over and over and over again... until we know that we're above that limit. Submit and... awesome:

This value is too long. It should have 255 characters or less.

Behind-the-scenes, auto-mapping also added an @Length annotation to limit this field to the column size. Say goodbye to accidentally allowing large input... that then gets truncated in the database.

Disabling Auto-Mapping when it Doesn't Make Sense

As cool as this feature is, automatic functionality will never work in all cases. And that's fine for two reasons. First, it's your choice to opt-into this feature by adding the @EnableAutoMapping annotation:

... lines 1 - 12
use Symfony\Component\Validator\Constraints as Assert;
... lines 14 - 15
/**
... line 17
* @Assert\EnableAutoMapping()
*/
class Article
{
... lines 22 - 324
}

And second, you can disable it on a field-by-field basis.

A great example of when this feature can be a problem is in the User class. Imagine we added @EnableAutoMapping here and created a registration form bound to this class. Well... that's going to be a problem because it will add a NotNull constraint to the $password field! And we don't want that!

... lines 1 - 19
class User implements UserInterface
{
... lines 22 - 47
/**
* @ORM\Column(type="string", length=255)
*/
private $password;
... lines 52 - 283
}

In a typical registration form - like the one that the make:registration-form command creates - the $password property is set to its hashed value only after the form is submitted & validated. Basically, this is not a field the user sets directly and having the NotNull constraint causes a validation error on submit.

How do you solve this? You could disable auto-mapping for the whole class. Or, you could disable it for the $password property only by adding @Assert\DisableAutoMapping:

// src/Entity/User.php

class User implements UserInterface
{
    // ...
    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\DisableAutomapping()
     */
    private $password;
    // ...
}

This is the one ugly case for this feature, but it's easy to fix.

Configuring Auto-Mapping Globally

Oh, and one more thing! You can control the feature a bit in config/packages/validator.yaml. By default, you need to enable auto-mapping on a class-by-class basis by adding the @Assert\EnableAutoMapping annotation:

... lines 1 - 12
use Symfony\Component\Validator\Constraints as Assert;
... lines 14 - 15
/**
... line 17
* @Assert\EnableAutoMapping()
*/
class Article
{
... lines 22 - 324
}

But, you can also automatically enable it for specific namespaces:

framework:
validation:
... lines 3 - 4
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []

If we uncommented this App\Entity line, every entity would get auto-mapped validation without needing the extra annotation. I like being a bit more explicit - but it's your call.

Next, ready to talk about something super geeky? No, not Star Trek, but that would awesome. This is probably even better: let's chat about password hashing algorithms. Trust me, it's actually pretty neat stuff. Specifically, I want to talk about safely upgrading hashed passwords in your database to stay up-to-date with security best-practices.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.3.0",
        "ext-iconv": "*",
        "antishov/doctrine-extensions-bundle": "^1.4", // v1.4.2
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "doctrine/doctrine-bundle": "^2.0", // 2.0.6
        "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.1.2
        "doctrine/orm": "^2.5.11", // v2.7.2
        "easycorp/easy-log-handler": "^1.0", // v1.0.9
        "http-interop/http-factory-guzzle": "^1.0", // 1.0.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.8.1
        "knplabs/knp-paginator-bundle": "^5.0", // v5.0.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.7.0
        "knplabs/knp-time-bundle": "^1.8", // v1.11.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.3.0
        "nexylan/slack-bundle": "^2.1", // v2.2.1
        "oneup/flysystem-bundle": "^3.0", // 3.3.0
        "php-http/guzzle6-adapter": "^2.0", // v2.0.1
        "sensio/framework-extra-bundle": "^5.1", // v5.5.3
        "symfony/asset": "5.0.*", // v5.0.2
        "symfony/console": "5.0.*", // v5.0.2
        "symfony/dotenv": "5.0.*", // v5.0.2
        "symfony/flex": "^1.0", // v1.6.2
        "symfony/form": "5.0.*", // v5.0.2
        "symfony/framework-bundle": "5.0.*", // v5.0.2
        "symfony/mailer": "5.0.*", // v5.0.2
        "symfony/messenger": "5.0.*", // v5.0.2
        "symfony/monolog-bundle": "^3.5", // v3.5.0
        "symfony/security-bundle": "5.0.*", // v5.0.2
        "symfony/sendgrid-mailer": "5.0.*", // v5.0.2
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "5.0.*", // v5.0.2
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "5.0.*", // v5.0.2
        "symfony/webpack-encore-bundle": "^1.4", // v1.7.2
        "symfony/yaml": "5.0.*", // v5.0.2
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.3.0
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "5.0.*", // v5.0.2
        "symfony/debug-bundle": "5.0.*", // v5.0.2
        "symfony/maker-bundle": "^1.0", // v1.14.3
        "symfony/phpunit-bridge": "5.0.*", // v5.0.2
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "5.0.*" // v5.0.2
    }
}