If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeUnfortunately, you can't use the @UniqueEntity()
validation constraint above a class that is not an entity: it's just a known limitation. But, fortunately, this gives us the perfect excuse to create a custom validation constraint! Woo!
When you can't find a built-in validation constraint that does what you need, the next thing to try is the @Assert\Callback
constraint. We use this in the Article
class. But, it has one limitation: because the method lives inside an entity class - we do not have access to any services. In our case, in order to know whether or not the email
is taken yet, we need to make a query and so we do need to access a service.
When that's your situation, it's time for a custom validation constraint. They're awesome anyways and we're going to cheat! Find your terminal and run:
php bin/console make:validator
Call the class, how about, UniqueUser
. Oh, this created two classes: UniqueUser
and UniqueUserValidator
. You'll find these inside a new Validator/
directory. Look at UniqueUser
first: it's basically a dumb configuration object. This will be the class we use for our annotation.
... lines 1 - 6 | |
/** | |
* @Annotation | |
*/ | |
class UniqueUser extends Constraint | |
{ | |
/* | |
* Any public properties become valid options for the annotation. | |
* Then, use these in your validator class. | |
*/ | |
public $message = 'The value "{{ value }}" is not valid.'; | |
} |
The actual validation is handled by UniqueUserValidator
: Symfony will pass it the value being validated and a Constraint
object - which will be that UniqueUser
object we just saw. We'll use it to read some options to help us get our job done. For example, in the generated code, it reads the message
property from the $constraint
and sets that as the validation error. That's literally reading this public $message
property from UniqueUser
.
... lines 1 - 7 | |
class UniqueUserValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
/* @var $constraint App\Validator\UniqueUser */ | |
$this->context->buildViolation($constraint->message) | |
->setParameter('{{ value }}', $value) | |
->addViolation(); | |
} | |
} |
Ok: let's bring this generated code to life! Step 1: make sure your annotation class - UniqueUser
- is ready to go. In general, an annotation can either be added above a class or above a property. Well, you can also add annotations above methods - that works pretty similar to properties.
If you add a validation annotation above your class, then during validation, the value that's passed to that validator is the entire object. If you add it above a property, then the value that's passed is just that property's value. So, if you need access to multiple fields on an object for validation, then you'll need to create an annotation that can be used above the class. In this situation, I'm going to delete @UniqueEntity
and, instead, add the new annotation above my $email
property: @UniqueUser
. Hit tab to auto-complete that and get the use
statement.
... lines 1 - 4 | |
use App\Validator\UniqueUser; | |
... lines 6 - 7 | |
class UserRegistrationFormModel | |
{ | |
/** | |
... lines 11 - 12 | |
* @UniqueUser() | |
*/ | |
public $email; | |
... lines 16 - 26 | |
} |
Nice! Now, go back to your annotation class, we need to do a bit more work. To follow an example, press shift+ shift and open the core NotBlank
annotation class. See that @Target()
annotation above the class? This is a special annotation... that configures, um, the annotation system! @Target
tells the annotation system where your annotation is allowed to be used. Copy that and paste it above our class. This says that it's okay for this annotation to be used above a property, above a method or even inside of another annotation... which is a bit more of a complex case, but we'll leave it.
... lines 1 - 6 | |
/** | |
... line 8 | |
* @Target({"PROPERTY", "ANNOTATION"}) | |
*/ | |
class UniqueUser extends Constraint | |
... lines 12 - 19 |
What if you instead want your annotation to be put above a class? Open the UniqueEntity
class as an example. Yep, you would use the CLASS
target. The other thing you would need to do is override the getTargets()
method. Wait, why is there an @Target
annotation and a getTargets()
method - isn't that redundant? Basically, yep! These provide more or less the same info to two different systems: the annotation system and the validation system. The getTargets()
method defaults to PROPERTY
- so you only need to override it if your annotation should be applied to a class.
Phew! The last thing we need to do inside of UniqueUser
is give it a better default $message
: we'll set it to the same thing that we have above our User
class: I think you've already registered
. Paste that and... cool!
... lines 1 - 10 | |
class UniqueUser extends Constraint | |
{ | |
... lines 13 - 16 | |
public $message = 'I think you\'re already registered!'; | |
} |
If you need to be able to configure more things on your annotation - just create more public properties on UniqueUser
. Any properties on this class can be set or overridden as options when using the annotation. In UserRegistrationFormModel
, I won't do it now, but we could add a message=
option: that string would ultimately be set on the message
property.
Before we try this, go to UniqueUserValidator
. See the setParameter()
line? The makes it possible to add wildcards to your message - like:
The email {{ value }} is already registered
We could keep that, but since I'm not going to use it, I'll remove it. And... cool! With this setup, when we submit, this validator will be called and it will always fail. That's a good start. Let's try it!
Move over and refresh to resubmit the form. Yes! Our validator is working... it just doesn't have any logic yet! This is the easy part! Let's think about it: we need to make a query from inside the validator. Fortunately, these validator classes are services. And so, we can use our favorite trick: dependency injection!
Add an __construct()
method on top with a UserRepository $userRepository
argument. I'll hit alt+Enter to create that property and set it. Below, let's say $existingUser = $this->userRepository->findOneBy()
to query for an email set to $value
. Remember: because we put the annotation above the email
property, $value
will be that property's value.
Next, very simply, if (!$existingUser)
then return
. That's it.
... lines 1 - 8 | |
class UniqueUserValidator extends ConstraintValidator | |
{ | |
private $userRepository; | |
public function __construct(UserRepository $userRepository) | |
{ | |
$this->userRepository = $userRepository; | |
} | |
... line 17 | |
public function validate($value, Constraint $constraint) | |
{ | |
$existingUser = $this->userRepository->findOneBy([ | |
'email' => $value | |
]); | |
if (!$existingUser) { | |
return; | |
} | |
... lines 27 - 31 | |
} | |
} |
One note: if this were an edit form where a user could change their email, this validator would need to make sure that the existing user wasn't actually just this user, if they submitted without changing their email. In that case, we would need $value
to be the entire object so that we could use the id
to be sure of this. To do that, you would need to change UniqueUser
so that it lives above the class, instead of the property. You would also need to add an id
property to UserRegistrationFormModel
.
But, for us, this is it! Move back over, refresh and... got it! Try entering a new user and adding the novalidate
attribute so we can be lazy and keep the other fields blank. Submit! Error gone. Try WillRyker@theenterprise.org
with the same novalidate
trick. And... the error is back.
Custom validation constraints, check! Next, we're going to update our Article form to add a few new drop-down select fields, but... with a catch: when the user selects an option from the first drop-down, the options of the second drop-down will need to update dynamically. Woh.
// composer.json
{
"require": {
"php": "^7.1.3",
"ext-iconv": "*",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
"knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
"knplabs/knp-time-bundle": "^1.8", // 1.8.0
"nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
"php-http/guzzle6-adapter": "^1.1", // v1.1.1
"sensio/framework-extra-bundle": "^5.1", // v5.2.1
"stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
"symfony/asset": "^4.0", // v4.1.6
"symfony/console": "^4.0", // v4.1.6
"symfony/flex": "^1.0", // v1.17.6
"symfony/form": "^4.0", // v4.1.6
"symfony/framework-bundle": "^4.0", // v4.1.6
"symfony/orm-pack": "^1.0", // v1.0.6
"symfony/security-bundle": "^4.0", // v4.1.6
"symfony/serializer-pack": "^1.0", // v1.0.1
"symfony/twig-bundle": "^4.0", // v4.1.6
"symfony/validator": "^4.0", // v4.1.6
"symfony/web-server-bundle": "^4.0", // v4.1.6
"symfony/yaml": "^4.0", // v4.1.6
"twig/extensions": "^1.5" // v1.5.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
"easycorp/easy-log-handler": "^1.0.2", // v1.0.7
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
"symfony/dotenv": "^4.0", // v4.1.6
"symfony/maker-bundle": "^1.0", // v1.8.0
"symfony/monolog-bundle": "^3.0", // v3.3.0
"symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
"symfony/profiler-pack": "^1.0", // v1.0.3
"symfony/var-dumper": "^3.3|^4.0" // v4.1.6
}
}