Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Validating Who/When Can Publish

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

I have an idea! Let's complicate things!

We can't let anyone publish a CheeseListing: only the owner or an admin should be able to do that. But we already have that covered thanks to the security attribute on the CheeseListing put operation:

... lines 1 - 17
/**
* @ApiResource(
... lines 20 - 21
* itemOperations={
... lines 23 - 25
* "put"={
* "security"="is_granted('EDIT', object)",
* "security_message"="Only the creator can edit a cheese listing"
* },
... line 30
* },
... lines 32 - 43
* )
... lines 45 - 55
*/
class CheeseListing
{
... lines 59 - 215
}

It has "security"="is_granted('EDIT', object), which activate a custom voter that we created in the last tutorial: CheeseListingVoter:

... lines 1 - 10
class CheeseListingVoter extends Voter
{
... lines 13 - 19
protected function supports($attribute, $subject)
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT'])
&& $subject instanceof CheeseListing;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
/** @var CheeseListing $subject */
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'EDIT':
if ($subject->getOwner() === $user) {
return true;
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
return false;
}
throw new \Exception(sprintf('Unhandled attribute "%s"', $attribute));
}
}

This looks for that EDIT attribute and checks to make sure that the current user is the owner of the CheeseListing or is an admin. So it's already true that only the owner or an admin can even hit this put operation.

But now we have a new requirement: to prevent low-quality listings from being added, our business team has told us that an owner can only publish a CheeseListing if the description is at least 100 characters. If it's shorter, publishing should fail with a 400 status code. Oh... but the business people also said that this rule should not apply to admins: they should be able to publish no matter how short the description is.

Using Foundry State to Make a Longer Description

Um... ok! Let's start with a test to describe all this craziness. In CheeseListingResourceTest, to make sure that our testPublishCheeseListing() keeps working, we need to give CheeseListing a longer description.

If you look in CheeseListingFactory - src/Factory/CheeseListingFactory - by default, the description is short:

... lines 1 - 19
final class CheeseListingFactory extends ModelFactory
{
... lines 22 - 33
protected function getDefaults(): array
{
return [
... line 37
'description' => 'What can I say? A raw cube of cheese power',
... lines 39 - 41
];
}
... lines 44 - 56
}

But I've already added something called a "state method": withLongDescription():

... lines 1 - 19
final class CheeseListingFactory extends ModelFactory
{
... lines 22 - 26
public function withLongDescription(): self
{
return $this->addState([
'description' => self::faker()->paragraphs(3, true)
]);
}
... lines 33 - 56
}

If we call this on the factory, it will set the description to three paragraphs via Faker... which will definitely be long enough.

In other words, inside the test, we can add ->withLongDescription() to make sure that the CheeseListing created will have a long enough description:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 70
public function testPublishCheeseListing()
{
... lines 73 - 75
$cheeseListing = CheeseListingFactory::new()
->withLongDescription()
->create(['owner' => $user]);
... lines 79 - 93
}
... lines 95 - 146
}

Thanks to this, once we add the new restrictions, this test should still pass.

Testing the Validation Requirements

Below this, I'm going to paste in a new test method, which you can get from the code block on this page:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
$client = self::createClient();
$user = UserFactory::new()->create();
$adminUser = UserFactory::new()->create(['roles' => ['ROLE_ADMIN']]);
$cheeseListing = CheeseListingFactory::new()
->create(['owner' => $user, 'description' => 'short']);
// 1) the owner CANNOT publish with a short description
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
$this->assertResponseStatusCodeSame(400, 'description is too short');
// 2) an admin user CAN publish with a short description
$this->logIn($client, $adminUser);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
$this->assertResponseStatusCodeSame(200, 'admin CAN publish a short description');
$cheeseListing->refresh();
$this->assertTrue($cheeseListing->getIsPublished());
// 3) a normal user CAN make other changes to their listing
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['price' => 12345]
]);
$this->assertResponseStatusCodeSame(200, 'user can make other changes on short description');
$cheeseListing->refresh();
$this->assertSame(12345, $cheeseListing->getPrice());
// 4) a normal user CANNOT unpublish
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => false]
]);
$this->assertResponseStatusCodeSame(400, 'normal user cannot unpublish');
// 5) an admin user CAN unpublish
$this->logIn($client, $adminUser);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => false]
]);
$this->assertResponseStatusCodeSame(200, 'admin can unpublish');
$cheeseListing->refresh();
$this->assertFalse($cheeseListing->getIsPublished());
}
... lines 146 - 197
}

Let's walk through it real quick.

We start by creating a CheeseListing with a short description, logging in as the owner of that listing, trying to publish it and checking for a 400 status code:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
$client = self::createClient();
$user = UserFactory::new()->create();
$adminUser = UserFactory::new()->create(['roles' => ['ROLE_ADMIN']]);
$cheeseListing = CheeseListingFactory::new()
->create(['owner' => $user, 'description' => 'short']);
// 1) the owner CANNOT publish with a short description
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
$this->assertResponseStatusCodeSame(400, 'description is too short');
... lines 111 - 144
}
... lines 146 - 197
}

Next, we log in as an admin user, do the same thing and check that this does work:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 111
// 2) an admin user CAN publish with a short description
$this->logIn($client, $adminUser);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
$this->assertResponseStatusCodeSame(200, 'admin CAN publish a short description');
$cheeseListing->refresh();
$this->assertTrue($cheeseListing->getIsPublished());
... lines 120 - 144
}
... lines 146 - 197
}

I also have a test that logs back in as the normal user and changes something different... just to make sure normal edits still work:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 120
// 3) a normal user CAN make other changes to their listing
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['price' => 12345]
]);
$this->assertResponseStatusCodeSame(200, 'user can make other changes on short description');
$cheeseListing->refresh();
$this->assertSame(12345, $cheeseListing->getPrice());
... lines 129 - 144
}
... lines 146 - 197
}

Finally, the last two parts are for unpublishing. In our system, unpublishing is something that only an admin can do. To test that, we log in as a normal user, try to set isPublished to false and make sure it doesn't work:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 129
// 4) a normal user CANNOT unpublish
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => false]
]);
$this->assertResponseStatusCodeSame(400, 'normal user cannot unpublish');
... lines 136 - 144
}
... lines 146 - 197
}

Then we log in as an admin and make sure that it does work:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 136
// 5) an admin user CAN unpublish
$this->logIn($client, $adminUser);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => false]
]);
$this->assertResponseStatusCodeSame(200, 'admin can unpublish');
$cheeseListing->refresh();
$this->assertFalse($cheeseListing->getIsPublished());
}
... lines 146 - 197
}

Phew! And yes, we could - and maybe should - break this test into smaller test methods to make debugging easier. I'm being a bit lazy by combining them.

Is this Security? Or Validation?

So how can accomplish all of this? Well first... is this a security check or a validation check? Because sometimes, the difference between the two is blurry.

In theory, we could do something inside the security attribute. The expression has a variable called previous_object, which is the object before it was updated. So, we could compare the previous object's isPublished with the object's isPublished and do different things. Or, to keep this readable, we could even pass both the object and previous_object into a voter as an array. That seems a little crazy to me... but it would probably work!

However, I tend to view things like this: security is best when you're trying to completely prevent access to an operation. Validation is best when the restrictions you need to apply are based on the data that's being sent, like preventing a field from changing to some value. That's true even if those values depend on the authenticated user.

In other words, we're going to create a custom validator! At your terminal, run:

php bin/console make:validator

I'll call it: ValidIsPublished.

This generated two classes: a class that represents the "annotation" and also a class that holds the actual validator logic. We can see both inside src/Validator/.

Before we add any logic to either class, let's use the annotation in CheeseListing. So, above the CheeseListing class, add @ValidIsPublished():

... lines 1 - 11
use App\Validator\ValidIsPublished;
... lines 13 - 18
/**
... lines 20 - 56
* @ValidIsPublished()
*/
class CheeseListing
{
... lines 61 - 217
}

Now, the reason we're adding this above the class - and not above the $isPublished property - is that our validator will need access to the entire CheeseListing object so that it can check both the isPublished and description properties. If we had put this above isPublished, then only that field would be passed to us.

Let's try the test and see what the failure looks like right now. Over in the test class, copy the new method name, and then run:

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

And... it had an error! Let's see what's going on:

Constraint ValidIsPublished cannot be put on classes

Ah! By default, constraint annotations are only allowed to be placed above properties, not above the class. To change that, inside the validator class, we need to do two things. First, add @Target() and pass this "CLASS":

... lines 1 - 4
use Doctrine\Common\Annotations\Annotation\Target;
... lines 6 - 7
/**
... line 9
* @Target({"CLASS"})
*/
class ValidIsPublished extends Constraint
{
... lines 14 - 23
}

Second, I'll go to "Code"->"Generate" - or Command+N on a Mac - and select "Override methods" to override getTargets(). Make this return: self::CLASS_CONSTRAINT:

... lines 1 - 11
class ValidIsPublished extends Constraint
{
... lines 14 - 19
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

Now our annotation can be put above a class.

Grabbing the CheeseListing inside the Validator

This means that when our CheeseListing is validated, Symfony will call ValidIsPublishedValidator and this $value will be the CheeseListing object:

... lines 1 - 7
class ValidIsPublishedValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
... lines 12 - 21
}
}

Real quick: if you're not familiar with how custom validators work, by default if I add @ValidIsPublished, then Symfony will look for a service called ValidIsPublishedValidator. So these two classes are already connected via this naming convention.

Let's start with a sanity check... right here: if not $value instanceof CheeseListing, then throw a new \LogicException - though the exact exception class doesn't matter - and say:

Only CheeseListing is supported.

... lines 1 - 4
use App\Entity\CheeseListing;
... lines 6 - 8
class ValidIsPublishedValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
/* @var $constraint \App\Validator\ValidIsPublished */
if (!$value instanceof CheeseListing) {
throw new \LogicException('Only CheeseListing is supported');
}
... lines 18 - 28
}
}

This is just to make sure that if we accidentally add @ValidIsPublished above a different class, someone will yell at us. Now dd($value) so we can be absolutely sure that we know what this looks like:

... lines 1 - 8
class ValidIsPublishedValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
/* @var $constraint \App\Validator\ValidIsPublished */
if (!$value instanceof CheeseListing) {
throw new \LogicException('Only CheeseListing is supported');
}
dd($value);
... lines 20 - 28
}
}

Back to the tests! Run:

symfony run bin/phpunit --filter=testPublishCheeseListingValidation

And... Yes! The validator is called and our CheeseListing object has isPublished set to true because this is the CheeseListing after the new data has been deserialized into it. The failure is from our first test case: we currently allow this item to be published, even though the description is short. Next, let's prevent this. How? It's back to our "original data" trick from Doctrine.

Leave a comment!

7
Login or Register to join the conversation

How can I add a custom ConstraintValidatorFactory or replace the default ConstraintValidatorFactory?
I have ConstraintValidator which needs a Service injected.
But this works not with the default implementation of ConstraintValidatorFactory

Reply

Hey M_Holstein!

Sorry for the slow reply. The "constraint validation" classes are services, so you should be able to add a constructor and autowire services into it like normal. If you're seeing something different, then something isn't quite right...

Cheers!

1 Reply

Thanks for the reply.

Yes, our Code works with a new instance of ValidatorBuilder what I've seen later.

$validatorBuilder = new ValidatorBuilder();

This was why the classes not worked as expected.

But we have now a own Factory to be more flexible and set them to the ValidatorBuilder

public function initValidator(ObjectMetadataFactory $metadataFactory, ConstraintValidatorFactory $constraintValidatorFactory)
{
$validatorBuilder = new ValidatorBuilder();
$validatorBuilder->setMetadataFactory($metadataFactory);
$validatorBuilder->setConstraintValidatorFactory($constraintValidatorFactory);


$this->validator = $validatorBuilder->getValidator();
}

We need custom Validators for different properties and special logic to get the instance of the ConstraintValidator that's why we need custom getInstance() method of the factory.

Regards
Mark

Reply

Hey M_Holstein!

> $validatorBuilder = new ValidatorBuilder();

Hmm, you have this code? Are you using the the Symfony Framework or API Platform in a custom application (with no framework)? I'm asking because, in the Symfony Framework, you are not responsible for instantiating the ValidatorBuilder: it's already a service in the container and it's passed a ConstraintValidatorFactory instance that's able to load custom ConstraintValidators from the service container. So, are you outside of Symfony? If not, what's the reason for instantiating the validator builder manually?

Cheers!

Reply

weaverryan , yes we have this in a Symfony bundle.
We need to set Metadatafactory and ConstraintValidatorFactory . If we do this in the ValidatorBuilder Service, we became errors like this

Symfony\Component\Validator\Exception\ValidatorException : You cannot enable annotation mapping after setting a custom metadata factory. Configure your metadata factory instead.

If the metadata factory was once set in the ValidatorBuilder object, no other setter of the object can be used.
I didn't understand the message Configure your metadata factory instead. fully.
How and where could I do this?

We also can not used annotations for the constraints, because we only know after instantiating the object what kind of validation we need. For example: Assert\Count it can be 1 or 3 or 5.

I have no way found how to change this on a Object. Only on classes.

You have to know, that we have have a entity that can be everything.

A Car, a bike, a plane, picture data. Everything. :-)
These are made by dynamic amount of attributes via a attributes entity.

So that`s why we like to have a service that can check this object against Constraint dynamic.

Regards
Mark

Reply

Hey M_Holstein!

Ah... I understand much better now! In general, if you're going to try to create the validator manually, you're probably going to run into limitations. So the "ideal" answer is to try to extend the existing service, without totally replacing it.

You mentioned you're doing this because you need to set a custom MetadataFactory and a custom ConstraintValidationFactory. I've never had to do either of these, but let's see what we can discover:

MetadataFactory

This class is created inside the normal ValidatorBuilder service automatically. Basically, if someone calls getValidator() and the setMetadataFactory() has not been called yet (and I'm 95% sure that it is NOT called by default in Symfony_, then it will be created automatically.

However, I'm not sure that you really need to REPLACE the metadata factory. There is already a mechanism in the ValidatorBuilder to extend the metadata factory: by creating a "loader" class and calling ->addLoader() on the ValidatorBuilder.

I don't think there is a built-in way to do this. So the process would be:

A) Create a class that loads your custom metadata and make it implement LoaderInterface (here is an example: https://github.com/symfony/... ).

B) Create a compiler pass - https://symfony.com/doc/cur... - where you fetch the validator.builder service definition and then add a "call" to it to call setLoader() and pass in your new service. The logic would look something like:


$definition = $container->findDefinition('validator.builder');
$definition->addMethodCall('addLoader', [new Reference(YourCustomLoaderClass::class)]);

ConstraintValidationFactory

The default ConstraintValidationFactory is the validator.validator_factory. Instead of replacing this, I would decorate it using service decoration - https://symfony.com/doc/cur...

So, you would create a new class that implements ConstraintValidatorFactoryInterface, add your custom logic, and call the "inner" constraint validation factory for the "normal" functionality. Then wire the service decoration in services.yaml. The result will be that Symfony will start using YOUR custom ConstraintValidatorFactoryInterface service, and will pass you the normal, core one.

I hope this helps!

1 Reply

weaverryan thank you very much for this.
I think this will improve our code a lot. :)
I will definitely try this.

Validator and constraints and the customization of this are a very complicated topic. ;)

Thx again.

Regards
Mark

Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.0 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}