Login to bookmark this video
Buy Access to Course
32.

Custom Validator

|

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

If you need to control how a field like isPublished is set based on who is logged in, you have two different situations.

Protecting a Field vs Protecting its Data

First, if you need to prevent certain users from writing to this field entirely, that's what security is for. The easiest option is to use the #[ApiProperty(security: ...)] option that we used earlier above the property. Or you could get fancier and add a dynamic admin:write group via a context builder. Either way, we're preventing this field from being written entirely.

The second situation is when a user should be allowed to write to a field... but the valid data they're allowed to set depends on who they are. Like maybe a user is allowed to set isPublished to false... but they're not allowed to set it to true unless they're an admin.

Let me give you a different example. Right now, when you create a DragonTreasure, we force the client to pass an owner. We can see this in testPostToCreateTreasure(). We're going to fix this in a few minutes so that we can leave this field off... and then it'll be set automatically to whoever is authenticated.

But right now, the owner field is allowed and required. But who they are allowed to assign as the owner depends on who is logged in. For normal users, they should only be allowed to assign themselves as a user. But for admins, they should be able to assign anyone as the owner. Heck, maybe in the future we get crazier and there are clans of dragons... and you can create treasures and assign them to anyone in your clan The point is: the question isn't if we can set this field, but what data we're allowed to set it to. And that depends on who we are.

Solving with Security or Validation?

Ok, actually, we solved this problem earlier for the Patch() operation. Let me show you. Find testPatchToUpdateTreasure(). Then... let's run just that test:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

And... it passes. This test checks 3 things. First, we log in as the user that owns the DragonTreasure and make an update. That's the happy case!

Next, we log in as a different user and try to edit the first user's DragonTreasure. That is not allowed. And that is a proper use of security: we don't own this DragonTreasure, so we are not at all allowed to edit it. That's what the security line is protecting.

For the last part, we log in again as the owner of this DragonTreasure. But then we try to change the owner to someone else. That's also not allowed and this is the situation we're talking about. It's currently handled by securityPostDenormalize(). But I want to handle it instead with validation. Why? Because the question we're answering is this:

Is the owner data that's sent valid?

And... validating data is... the job of validation!

Remove the securityPostDenormalize():

251 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 28
#[ApiResource(
// ... lines 30 - 31
operations: [
// ... lines 33 - 41
new Patch(
// ... line 43
securityPostDenormalize: 'is_granted("EDIT", object)',
),
// ... lines 46 - 48
],
// ... lines 50 - 66
)]
// ... lines 68 - 88
class DragonTreasure
{
// ... lines 91 - 249
}

And to prove this was important, run the test again:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

Yup! It failed on line 132... which is this one down here. Let's rewrite this with a custom validator, which is actually a lot nicer.

Creating the Custom Validation

Oh but because this will fail via validation when we're done, change to assertStatus(422):

// ... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
// ... lines 15 - 97
public function testPatchToUpdateTreasure()
{
// ... lines 100 - 126
$this->browser()
// ... lines 128 - 134
->assertStatus(422)
;
}
// ... lines 138 - 179
}

The idea is that we are allowed to PATCH this user, but we sent invalid data: we can't set this owner to someone other than ourselves.

Ok, head to the command line and run:

php ./bin/console make:validator

Give it a cool name like IsValidOwnerValidator. In Symfony, validators are two different classes. Open src/Validator/IsValidOwner.php first:

21 lines | src/Validator/IsValidOwner.php
// ... lines 1 - 2
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class IsValidOwner 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.';
}

This lightweight class will be used as the attribute... and it just holds options that we can configure, like $message, which is enough. Let's change the default message to something a bit more helpful:

21 lines | src/Validator/IsValidOwner.php
// ... lines 1 - 12
class IsValidOwner extends Constraint
{
// ... lines 15 - 18
public string $message = 'You are not allowed to set the owner to this value.';
}

The second class is the one that will be executed to handle the logic:

24 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 2
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
/* @var App\Validator\IsValidOwner $constraint */
if (null === $value || '' === $value) {
return;
}
// TODO: implement the validation here
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}

We'll look at that in a moment... but let's use the new constraint first. Over in DragonTreasure, down on the owner property... there we go... add the new attribute: IsValidOwner:

252 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 19
use App\Validator\IsValidOwner;
// ... lines 21 - 88
class DragonTreasure
{
// ... lines 91 - 135
#[IsValidOwner]
// ... line 137
private ?User $owner = null;
// ... lines 139 - 250
}

Filling in the Validator Logic

Now that we have this, when our object is validated, Symfony will call IsValidOwnerValidator and pass us the $value - which will be the User object - and the constraint, which will be IsValidOwner.

Let's do some clean up. Remove the var and replace it with assert($constraint instanceof IsValidOwner):

26 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
assert($constraint instanceof IsValidOwner);
if (null === $value || '' === $value) {
return;
}
// ... lines 18 - 23
}
}

That's just to help my editor: we know that Symfony will always pass us that. Next, notice that it's checking to see if the $value is null or blank. And if is, it does nothing. If the $owner property is empty, that should really be handled by a different constraint.

Back in DragonTreasure, add #[Assert\NotNull]:

253 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 88
class DragonTreasure
{
// ... lines 91 - 136
#[Assert\NotNull]
// ... line 138
private ?User $owner = null;
// ... lines 140 - 251
}

So if they forget to send owner, this will handle that validation error. Back inside our validator, if we have that situation, we can just return:

26 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
// ... lines 13 - 14
if (null === $value || '' === $value) {
return;
}
// ... lines 18 - 23
}
}

Below this, add one more assert() that $value is an instanceof User.

Really, Symfony will pass us whatever value is attached to this property... but we know that this will always be a User:

26 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
// ... lines 13 - 14
if (null === $value || '' === $value) {
return;
}
// constraint is only meant to be used above a User property
assert($value instanceof User);
// ... lines 21 - 23
}
}

Finally, delete setParameter() - that's not needed in our case - and $constraint->message is reading the $message property:

26 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 8
class IsValidOwnerValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
assert($constraint instanceof IsValidOwner);
if (null === $value || '' === $value) {
return;
}
// constraint is only meant to be used above a User property
assert($value instanceof User);
$this->context->buildViolation($constraint->message)
->addViolation();
}
}

At this point, we have a functional validator! Except... it's going to fail in all situations. Ah, let's at least make sure it's being called. Run our test:

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

Beautiful failure! A 422 coming from DragonTreasureResourceTest line 110... because our constraint is never satisfied.

Checking for Ownership in the Validator

Finally we can add our business logic. To do the owner check, we need to know who's logged in. Add a __construct() method, autowire our favorite Security class... and I'll put private in front of that, so it becomes a property:

36 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 5
use Symfony\Bundle\SecurityBundle\Security;
// ... lines 7 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
public function __construct(private Security $security)
{
}
// ... lines 15 - 34
}

Below, set $user = $this->security->getUser(). And if there is no user for some reason, throw a LogicException to make things explode:

36 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
// ... lines 12 - 15
public function validate($value, Constraint $constraint)
{
// ... lines 18 - 23
// constraint is only meant to be used above a User property
assert($value instanceof User);
$user = $this->security->getUser();
if (!$user) {
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.');
}
// ... lines 31 - 33
}
}

Why not trigger a validation error? We could... but in our app, if an anonymous user is somehow successfully changing a DragonTreasure... we have some sort of misconfiguration.

Finally, if $value does not equal $user - so if the owner is not the User - add that validation failure:

38 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
// ... lines 12 - 15
public function validate($value, Constraint $constraint)
{
// ... lines 18 - 31
if ($value !== $user) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

That's it! Let's try this thing!

symfony php bin/phpunit --filter=testPatchToUpdateTreasure

And... bingo! Whether we're creating or editing a DragonTreasure, we are not allowed to set the owner to someone that is not us.

And we can add whatever other fanciness we want. Like if the user is an admin, return so that admin users are allowed to assign the owner to anyone:

42 lines | src/Validator/IsValidOwnerValidator.php
// ... lines 1 - 9
class IsValidOwnerValidator extends ConstraintValidator
{
// ... lines 12 - 15
public function validate($value, Constraint $constraint)
{
// ... lines 18 - 26
$user = $this->security->getUser();
if (!$user) {
throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.');
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
if ($value !== $user) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

I love this. But... there's still one big security hole: a hole that will allow a user to steal the treasures of someone else! Not cool! Let's find out what that is next and crush it.