Publish State Change Validator Logic

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

We're working on adding some pretty complex validation rules around who can publish or unpublish a CheeseListing. To get this logic right, what we really need to know is how the $isPublished field is changing... like, is it changing from false to true? Or true to false? Or maybe it's not changing at all because the PUT request is only updating other fields.

And hey! We already know how to get the original data from Doctrine! We did it in CheeseListingDataPersister:

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
... lines 32 - 39
}
... lines 41 - 45
}

Oh, and by the way: if your API resource is not an entity - which is totally allowed and something that we'll talk about later - then you can get the original object by injecting the RequestStack, getting the current request and then reading the previous_data attribute:

use Symfony\Component\HttpFoundation\RequestStack;

class PizzaMachine
{
    private $requestStack;

    public function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    public function makePizza()
    {
        $request = $this->requestStack->getCurrentRequest();

        $originalData = $request->attributes->get('previous_data');
    }
}

If that attribute is not there, then you know that your object is being created.

Fetching the Original Entity Data

Ok: let's get the original data just like we did before. Add a public function __construct() with EntityManagerInterface $entityManager. I'll do my normal trick of hitting Alt+Enter and going to "Initialize properties" to create that property and set it:

... lines 1 - 5
use Doctrine\ORM\EntityManagerInterface;
... lines 7 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
... lines 18 - 40
}

Below in the method, we can say $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($value). Let's dd($originalData) to make sure it's working:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
/* @var $constraint \App\Validator\ValidIsPublished */
if (!$value instanceof CheeseListing) {
throw new \LogicException('Only CheeseListing is supported');
}
$originalData = $this->entityManager
->getUnitOfWork()
->getOriginalEntityData($value);
dd($originalData);
... lines 31 - 39
}
}

Spin over and try the test:

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

Got it! It's the same array that we saw earlier and it correctly shows that isPublished is originally false!

Remove the dd() and we also don't need this null check:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 29
dd($originalData);
if (null === $value || '' === $value) {
return;
}
... lines 35 - 39
}
}

You do need that when you add a validator to a property - which might be null - but not when your constraint is above a class. We will always get a CheeseListing object.

For the previous is published value, let's say: $previousIsPublished = $originalData['isPublished'] ?? false in case it's not set:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 26
$originalData = $this->entityManager
->getUnitOfWork()
->getOriginalEntityData($value);
$previousIsPublished = ($originalData['isPublished'] ?? false);
... lines 32 - 41
}
}

That's the same thing we did in CheeseListingDataPersister:

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
... line 31
$wasAlreadyPublished = ($originalData['isPublished'] ?? false);
... lines 33 - 39
}
... lines 41 - 45
}

Let's start by checking to see if the published field has not changed: if $previousIsPublished === $value->getIsPublished(), then we don't need to do any special validation. Just return, and I'll add a comment:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 30
$previousIsPublished = ($originalData['isPublished'] ?? false);
if ($previousIsPublished === $value->getIsPublished()) {
// isPublished didn't change!
return;
}
... lines 37 - 41
}
}

Not Allowing Short Descriptions

The first case in our test is that an owner cannot publish if the description is less than 100 characters.

To enforce this add if $value->getIsPublished():

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 30
$previousIsPublished = ($originalData['isPublished'] ?? false);
if ($previousIsPublished === $value->getIsPublished()) {
// isPublished didn't change!
return;
}
if ($value->getIsPublished()) {
... lines 39 - 47
}
}
}

Inside, we know that the field is changing from false to true: this listing is being published. So if strlen($value->getDescription()) < 100, we need to fail! Copy the buildViolation() code from below and move it up here:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 37
if ($value->getIsPublished()) {
// we are publishing!
if (strlen($value->getDescription()) < 100) {
$this->context->buildViolation('Cannot publish: description is too short!')
->atPath('description')
->addViolation();
}
return;
}
}
}

The argument is the validation message... and this is coming from the ValidIsPublished annotation:

... lines 1 - 11
class ValidIsPublished 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.';
... lines 19 - 23
}

This allows you to customize the message when you use it via annotation options:

... lines 1 - 18
/**
... lines 20 - 56
* @ValidIsPublished()
*/
class CheeseListing
{
... lines 61 - 217
}

But... I'm not going to use that: we're not creating a reusable constraint... so it's simpler to keep everything in one spot:

Cannot publish: description is too short!

We also don't need a parameter: that's if you need a dynamic wildcard in your message. Oh, but I will add ->atPath('description'):

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 37
if ($value->getIsPublished()) {
... lines 39 - 40
if (strlen($value->getDescription()) < 100) {
$this->context->buildViolation('Cannot publish: description is too short!')
->atPath('description')
->addViolation();
}
... lines 46 - 47
}
}
}

That's nice: it will make the validation failure look like it's attached to the description field even though we added the constraint to the entire class.

If the length is long enough, just return:

... lines 1 - 9
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 12 - 18
public function validate($value, Constraint $constraint)
{
... lines 21 - 37
if ($value->getIsPublished()) {
... lines 39 - 40
if (strlen($value->getDescription()) < 100) {
$this->context->buildViolation('Cannot publish: description is too short!')
->atPath('description')
->addViolation();
}
return;
}
}
}

Testing time! Run the test and...

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

It still fails... but... yes! It's now failing on "admin can publish a short description". Over in CheeseListingResourceTest, our first assertion passed! It is returning a 400 status code:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 104
// 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
}

Allowing Admin Users to Publish

It's failing now because when we log in as an admin user, it is also returning a 400 status code instead of allowing this:

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

To fix that, we need to see if the user is an admin. Add a second argument to the constructor Security $security. I'll initialize this property:

... lines 1 - 10
class ValidIsPublishedValidator extends ConstraintValidator
{
... line 13
private $security;
public function __construct(EntityManagerInterface $entityManager, Security $security)
{
... line 18
$this->security = $security;
}
... lines 21 - 53
}

Then below, update the if statement: if the description is too short and not $this->security->isGranted('ROLE_ADMIN'), fail validation. I'll add a comment to summarize this:

... lines 1 - 10
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 13 - 21
public function validate($value, Constraint $constraint)
{
... lines 24 - 40
if ($value->getIsPublished()) {
// we are publishing!
// don't allow short descriptions, unless you are an admin
if (strlen($value->getDescription()) < 100 && !$this->security->isGranted('ROLE_ADMIN')) {
... lines 46 - 48
}
... lines 50 - 51
}
}
}

This is true test-driven development. Try the test again:

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

It fails... but we are further!

Normal user cannot unpublish

If we look at the test... yea! The second and third cases are now passing and we're down to the unpublish logic:

... 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());
// 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');
... lines 136 - 144
}
... lines 146 - 197
}

You can start to see why breaking each test case into its own method might be a bit cleaner, even if it's more work up-front.

Adding the Unpublish Validation

Let's add the unpublishing validation logic, which is a bit easier: only an admin can unpublish. At the bottom of the validator - thanks to the return statement and the fact that we checked to make sure that the isPublished is changing, at the bottom, we know that this cheese listing is being un published.

If not $this->security->isGranted('ROLE_ADMIN') - if we're not an admin, then this is not allowed. Copy $context->buildViolation() from earlier, give it a nice message:

Only admin users can unpublish

And remove the atPath(): this has nothing to do with the description:

... lines 1 - 10
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 13 - 21
public function validate($value, Constraint $constraint)
{
... lines 24 - 40
if ($value->getIsPublished()) {
... lines 42 - 51
}
// we are UNpublishing
if (!$this->security->isGranted('ROLE_ADMIN')) {
$this->context->buildViolation('Only admin users can unpublish')
->addViolation();
}
}
}

Try it!

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

And... yes! That huge test now passes. Thanks to our validator and the original data, we're able to write the exact logic we need.

400 vs 403 Errors

When we set this up, we chose to use 400 validation errors:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 95
public function testPublishCheeseListingValidation()
{
... lines 98 - 109
$this->assertResponseStatusCodeSame(400, 'description is too short');
... lines 111 - 134
$this->assertResponseStatusCodeSame(400, 'normal user cannot unpublish');
... lines 136 - 144
}
... lines 146 - 197
}

Which is really nice because the user gets a 400 status code and can see a collection of descriptive validation errors.

If you wanted, you could instead return a 403 access denied, which might especially make sense when a normal user tries to unpublish. How could we do that?

One of the really cool things about the way Symfony is architected is that we are free - at any point during the request - to throw an AccessDeniedException. So literally, in the middle of the validator, we can say throw new AccessDeniedException() - make sure you to get the one from the Security component. I'll say:

Only admin users can unpublish

... lines 1 - 6
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
... lines 8 - 11
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 14 - 22
public function validate($value, Constraint $constraint)
{
... lines 25 - 54
// we are UNpublishing
if (!$this->security->isGranted('ROLE_ADMIN')) {
// you can return a 403
throw new AccessDeniedException('Only admin users can unpublish');
// or a normal validation error
$this->context->buildViolation('Only admin users can unpublish')
->addViolation();
}
}
}

To see this in action, run the test again:

symfony php bin/phpunit --filter=testPublishCheeseListingValidation

This will fail but... sweet! We can see the response: a giant 403 error. I'll comment that out and keep my validation logic:

... lines 1 - 11
class ValidIsPublishedValidator extends ConstraintValidator
{
... lines 14 - 22
public function validate($value, Constraint $constraint)
{
... lines 25 - 54
// we are UNpublishing
if (!$this->security->isGranted('ROLE_ADMIN')) {
// you can return a 403
//throw new AccessDeniedException('Only admin users can unpublish');
... lines 59 - 62
}
}
}

We now have a RESTful way to publish a listing and execute custom logic! In the fourth part of this series, we'll talk about other ways that we could have accomplished this, like using the Messenger integration or creating a truly custom operation. But I really like this solution.

Next: let's talk about how to add a completely custom field to your resource: a field that doesn't live in your entity and that might even require a service to calculate. We actually did this in a previous tutorial... but it was so custom that it didn't show up in our documentation. Let's learn an even better way.

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.6
        "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
    }
}