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!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.3
        "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.7.3
        "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.21.1
        "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.1.2
    }
}