Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Validator

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

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

Login Subscribe

Unfortunately, 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.

Generating the Constraint Validator

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();
}
}

Configuring the Annotation

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.

Configuring your Annotation Properties

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!

Filling in the Validator Logic

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.

Leave a comment!

42
Login or Register to join the conversation
Default user avatar

$existingUser = $this->userRepository->count([
'email' => $value
]);

should be a bit faster than the course code as it avoids fetching the entire user object

1 Reply
triemli Avatar
triemli Avatar triemli | posted 2 years ago

Can I use it in buildForm() instead of annotation?
Like:
$builder->add('field', CustomValidator::class);

Reply

Hey Sergei,

Yes, you can! See "constraints" field option: https://symfony.com/doc/cur... - it's an array of your constraints you want to apply to the form field. But keep in mind that this will work only for the form where you put that constraint. E.g. if you need the same validation in another form - you will need to add that constraint manually. But if you put that constraint on the entity - it will be applied globally in all forms.

Cheers!

Reply
triemli Avatar

I Just have 1 form and I want manually to validate a data.
I want to validate the password repeat but completely stuck with that.
I send on server two fields:

password
repeated_password

And in created form:


class ApiUserSignUpForm extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserModel::class,
'csrf_token_id' => 'head',
]);
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('repeated_password', RepeatedType::class, [
'first_name' => 'password',
'second_name' => 'repeated_password',
])
->add('password', PasswordType::class)
;
}
}

Try to validate it.
Validate is false with no errors.
Is it possible to check passwords, equals or not. Or I need to create custom valdiator with return $password === $repeated_password

Reply

Hey WebAdequate,

Did you dump that "repeated_password" value you get? IIRC it should be an array, something like: $data['repeated_password']['first'] and $data['repeated_password']['second'].

Btw, "repeated_password" already will give you 2 fields as you use RepeatedType::class type, see docs about it: https://symfony.com/doc/cur... . So, it sounds like you don't that extra "password" type. Or do you want to check that user enter his current password correctly before change his password to a new one from "repeated_password" field value?

Cheers!

Reply
triemli Avatar

Aha I didn't know it is a multiarray.

->add('pwd_fieldname', RepeatedType::class, [
'first_name' => 'name1',
'second_name' => 'name2',
])

$data['pwd_fieldname']['name1'] === $data['pwd_fieldname']['name2'] ?

Reply

Hey WebAdequate,

Yeah, it should be an array of nested arrays. Use "dd($data)" or "var_dump($data); die;" to see the actual structure of the form data and its array keys.

Cheers!

Reply
triemli Avatar

Thanks a lot for the answer! It works now.

Actually var_dump of $form is huge. It was difficult to explore.

So as I found out, I can't just use $form->getErrors() for rendering an erros. It always return for me 0.

But with $form->getErrors(true) I can iterate it. Like below:

Controller:


public function register(Request $request, JsonEncoder $encoder)
{
$raw = $request->getContent();
$data = $encoder->decode($raw, JsonEncoder::FORMAT);

$form = $this->createForm(ApiUserSignUpForm::class, new UserModel());

$form->submit($data['params']);
$form->isValid();

$errors = [];
if(!$form->isValid()) {
foreach ($form->getErrors(true) as $error) {
$errors[] = $error->getMessage();
}

return $this->json([
'success' => false,
'errors' => $errors
]);
}

$savedUser = $this->userService->signUpFromApi($form->getData());
return $this->json([
'success' => true,
'errors' => [],
'username' => $savedUser->getUsername(),
]);
}

Can we just receive simple array with ['username' => ['Username is too short!']] without this ugly iteration in the controller?

Reply

Hey WebAdequate,

Yay, good work!

Yes, vardumping it might be a problem as it's a big object that contains other objects. I'd recommend you to user dd() or dump(); die; instead - see Symfony's VarDumper component - it should handle it well.

About $form->getErrors(true) - yes, it will give you all the errors including errors of child forms.

> Can we just receive simple array with ['username' => ['Username is too short!']] without this ugly iteration in the controller?
Nothing much we can do here as it's an object that contains other objects. You can write a custom reusable code that will do what you want and just call one method. Well, you can try to serialize errors with a serializer, but I'm not sure it will give you the structure you need out of the box, so in any case you will have to add some custom code for this.

P.S. And looks like you call isValid() twice, it's redundant.

Cheers!

Reply
triemli Avatar

So i found this solution:


$errorArray = [];
$errors = $form->getErrors(true, true);
foreach($errors as $e){
$field = $e->getOrigin();
$errorArray[$field->getName()] = $errorArray[$field->getName()] ?? [];
$errorArray[$field->getName()][] = $e->getMessage();
}

Looks absolutely ugly and isn't native but it works fine.

Reply

Hey triemli!

Yep, this is a pretty manual way to get a flat array of all of the errors in the form - it's ugly as you said, but I have done this in the past in a few situations.

Can I ask why you're needing to flatten the array of errors? Are you returning the errors as JSON instead of re-rendering the form? If so, you might be better-served by using the validator directly and then serializing it with the serializer component (Symfony's serializer service knows how to nicely serialize the result of the validation component (validation error).

Cheers!

Reply
triemli Avatar
triemli Avatar triemli | weaverryan | posted 2 years ago | edited

Hi weaverryan this is solution for Vue application, because Vue works with JSON we sand a JSON for the form's errors render ;]

Reply

Hey triemli !

Ah, got it! So... you can actually just use the serializer :). Well, sort of. In Symfony 5.2 (not released yet), the serializer will have a "form normalizer" so that you can basically just do this:


return $this->json($form);

That's it! That *will* (in Symfony 5.2) automatically convert the errors on your form into a nice JSON response.

Since 5.2 isn't released yet, if you'd like, you could just move the new normalizer into your code: https://github.com/symfony/...

It should be as easy as "copy into your src/ directory" and then "update the namespace". Oh, and just in case, don't forget to install the serializer if you don't have it already - "composer require serializer".

Cheers!

Reply
triemli Avatar

Don't you find it strange when validation is false and $form->getErrors() is empty?

Reply

Yes, that's odd. How did you do that?

Reply
triemli Avatar

I just have a restriction on password field "length > 6". So basically I send password less than 6 symbols, validation is false (obviously) but $form->getErrors() is empty. $form['password']->getErrors() is empty too.

Reply

Sorry for my late reply! Hmm, that's very strange. If you open up the web profiler and go to the "forms" tab, what do you see after submitting an invalid form?

Reply
Christina V. Avatar
Christina V. Avatar Christina V. | posted 2 years ago

Hi everybody. I'm struggling with that story about the models.

Since the registration form, I have issues: Cannot access private property App\Entity\User::$email
So here is the incriminated part of the register function:


/** @var FormInterface $form */
$form = $this->createForm(UserRegistrationFormType::class);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {

/** @var UserRegistrationFormModel $userModel */
$userModel = $form->getData();

/** @var User $user */
$user = new User();

$user->setEmail($userModel->email);
$user->setRoles(['ROLE_USER']);
$user->setPassword($passwordEncoder->encodePassword(
$user,
$userModel->plainPassword
));

if (true === $userModel->agreeTerms) {
$user->agreeTerms();
}

$em = $this->getDoctrine()->getManager();
$em->persist($user);
$em->flush();
}

The error is located at $user->setEmail($userModel->email);

My UserRegistrationFormModel:





Help could be welcome, because I understand the meaning of this error but...

Reply

Hey Christina V.

I think you just forgot to make public the email field on your UserModel class

Cheers!

Reply
Default user avatar

Hi. In your UniqueUserValidator, is it possible to create another function of validation and call out ?

Reply

Hello!

I have 2 fields on my form : phone and mobile. At least one of them should be filled in. I guess that thee client side validation can only be done through javascript, right? But for the server side validation, is a custom validator (a class constraint) the right solution?

Thx!

Reply

Hey Lydie,

> I guess that thee client side validation can only be done through javascript

Do you mean 3 validations like make sure that phone is valid if specified (1), mobile is valid if specified (2), and at least one field is filled in (3)? Then this sounds good for me. But you can (should) do the same for server side validation as well.

For server side validation - yes, you can use a custom validator, or you can use Callback constraint as an alternative solution here: https://symfony.com/doc/cur... - it might be easier.

Cheers!

Reply

Hello victor !

Thx for your suggestion! I have implemented the callback method to test if at least one field is filled in:


/**
* @Assert\Callback()
*/
public function validate(ExecutionContextInterface $context, $payload)
{
if ((empty($this->getMobile()) && empty($this->getFixedPhone()))) {
$context->buildViolation('validation.phones')
->setTranslationDomain('prospects')
->atPath('mobile')
->addViolation();
}
}

Thx!

Reply

Hey Lydie,

Glad it helped! And thanks for sharing your solution with others :)

Cheers!

Reply

Hello!

Still have one open question for this callback. The validation is based on 2 fields of my form: mobile number and fixed phone number. The validation error message is attached to the mobile field and so it's displayed on top of this field. Would it be possible to specify a different location (for example a div id) ? Hope my question is clear enough :)

Reply

Hey Lydie,

Good question! You need to attach this validation constraint to the entire form instead of the specific field. IIRC, you just do not need that "->atPath('mobile')" because it attaches the message exactly to "mobile" field, but you most probably want to have this error message on an entire form as it's related to a few fields, or correctly say it relates to the entire form.

I hope this helps!

Cheers!

Reply

Thx for your answer, Victor ! We only have 2 possibilities: entire form or specific field? No way to display the error in the middle of the form ? For example, if you have your 2 fields next to each other (so on the same line), you will want to have the error on top of the line containing the 2 fields (to keep a nice layout)

Thx!

Reply

Hey Lydie,

Yes, only 2 options, display a form error or field specific error. But you can go wild and render the form your self ;) See this article in docs: https://symfony.com/doc/cur... - with {{ form_errors(form.fieldName) }} you can render specific form field error wherever you want :)

Cheers!

Reply
caglar Avatar

I followed the instructions to validate email on edit form. It works but it shows validator message on the top of the form. How can I change back the error's origin to email field?

Reply

Hey @Çağlar

That's a bit weird. Did you activate the "error_bubling" property on your form field?

Cheers!

Reply
caglar Avatar

The other validations' origin is their fields, I did not activate error_bubling. I think it is not weird, it just does not know on which field it needs to show error. I tried to imitate "UniqueEntity" to show it field but I could not make it work.
UniqueUser:
/**
* @Annotation
* @Target({"CLASS", "ANNOTATION"})
*/
class UniqueUser extends Constraint
{
public $fields = [];
public function getRequiredOptions()
{
return ['fields'];
}
public function getDefaultOption()
{
return 'fields';
}

/**
* {@inheritdoc}
*/
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}

/*
* Any public properties become valid options for the annotation.
* Then, use these in your validator class.
*/
public $message = 'The email "{{ value }}" is already used by another user.';
}

UserFormModel:
/**
* @UniqueUser(
* fields={"email"}
* )
*/
class UserFormModel
{
public $id;

/**
* @Assert\NotBlank()
*/
public $name;

/**
* @Assert\NotBlank()
* @Assert\Email()
*/
public $email;

UniqueUserValidator:
class UniqueUserValidator extends ConstraintValidator
{
private $userRepository;

public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}

public function validate($value, Constraint $constraint)
{
$existingUser = $this->userRepository->findOneByEmail($value->email, $value->id);

if (!$existingUser) {
return;
}

/* @var $constraint \App\Validator\UniqueUser */

$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value->email)
->addViolation();
}
}

Reply

Ohh, you created a Class constraint instead of a Property constraint, that's why the error appears as a global error, you can manipulate the error path by defining a public property "errorPath" on your constraint class. Then just set that value on your annotations:


/**
* @UniqueUser(
* fields={"email"}
* errorPath="email"
* )
*/
class UserFormModel
Reply
caglar Avatar

Thanks for helps. I also called atPath function in "UniqueUserValidator" to make it work:

$this->context->buildViolation($constraint->message)
->atPath($constraint->errorPath)
->setParameter('{{ value }}', $value->email)
->addViolation();

Reply
Adsasd Avatar

I found out, that you don't need to set the errorPath explicitly.

This is dynamic:


$errorPath = null !== $constraint->errorPath ? $constraint->errorPath : $fields[0];

$this->context->buildViolation($constraint->message)
->atPath($errorPath)
->setParameter('{{ value }}', $value->$fieldName)
->addViolation();

Btw. thanks SymfonyCast Team for your outstanding work!

Reply

Ah, sorry about that. I forgot to mention that step

Reply
Paul S. Avatar
Paul S. Avatar Paul S. | posted 3 years ago

My email & password validation error messages aren't showing anymore :( They are visible in the Profiler.validator calls but not being written to the page... Any ideas where I've went wrong?

Reply

Hey Paul S.

How did you build your login form? If you did it manually, then you will have to print those errors manually as well

Cheers!

Reply
Benoit L. Avatar

Hello, I need the answer to this very question : I'm facing this problem too, annotation is above a class, the invalid value if an object instead of the say email,
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value->getEmail())
->addViolation();

with this the display message can be viewed in the but it is not showing on the form, care to tell me how to do it "manually" thanks

Reply
Benoit L. Avatar

Ok I just figured out, you don't need to alter the entity, just have to add this line
$this->context->buildViolation($constraint->message)
->atPath('clientTelFixe'). // this line is important
->setParameter('{{ value }}', $value->getClientTelFixe())
->addViolation();

See https://symfony.com/doc/current/validation/custom_constraint.html

1 Reply

Hey Benoit L.

I'm glad to hear that you could find a solution. Thanks for sharing it

Cheers!

Reply
Kris Avatar

Hi, on video you said:

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.


..but our UserRegistrationFormModel isn't entity ;] and we can use autowiring ;]

Reply

Hey Kris!

Ah, very clever of you! But... nope! The reason an entity is not a service is not *actually* because it's an entity.. and so that makes it special somehow. The key thing is that an "entity" is something that we do NOT want handled by the container. Why? Because the container will only ever allow a single instance of an object. And so, these "data-holding" object don't fit well int that model. For example, if I want to display 10 products on a page, it TOTALLY makes sense to have 10 Product entity objects.

Let me say it a different way. If you code cleanly, you will have 2 different types of classes/objects

A) Simple model objects: objects that hold data but don't really do much work. It makes sense to have multiple instances of these objects at any given time. An entity is an example of this, but so UserRegistrationFormModel

B) Service objects: objects that do not hold much data (maybe just some config) and primarily do work. An important property of these is that it only makes sense to have ONE instance of these classes ever. For example, think of some "mailer" object. If you need to send 5 emails, do you need 5 Mailer instances? Nope - just 1 - and you would call some "sendEmail" message on it 5 times (just an example). THESE are the objects that the container is meant to instantiate.

Now, for your specific situation :). Do to how "friendly" Symfony's config is, you technically CAN use UserRegistrationFormModel as a service - Symfony just sees this as a class, and so if you, for example, add UserRegistrationForModel as an argument to a controller, Symfony will say "Oh, that must be a service, let's instantiate it, autowire all its arguments, and pass it in". But, this is improper use, and it'll bite you eventually. Most importantly, it starts to "blur" that clean distinction between model classes and service classes.

Let me know if that makes sense. What you're thinking is not surprising - I was actually wondering if this aspect would begin to confuse people :).

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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
    }
}