Buy Access to Course
06.

Running Code "On Publish"

|

Share this awesome video!

|

Oh, quick, minor thing about state processors. The make:state-processor command created the process() method with a void return. And... that makes sense. API Platform passes us the data and our job is just to save that... not return anything.

However, technically the process() method can return something. And, for consistency, I will return something. Remove the void type and, at the bottom, return $data.

34 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 11
class DragonTreasureStateProcessor implements ProcessorInterface
{
// ... lines 14 - 21
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
// ... lines 24 - 30
return $data;
}
}

I'll repeat this in UserHashPasswordStateProcessor for consistency.

// ... lines 1 - 11
class UserHashPasswordStateProcessor implements ProcessorInterface
{
// ... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
// ... lines 20 - 23
return $this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

Here's the deal: if you return something, that will be the "thing" that is ultimately serialized and returned as JSON. If you do not return anything, it will serialize $data. So, by returning $data... we're not changing any behavior. But it's interesting to know that you could return something different.

Detecting Changes: previous_data vs UnitOfWork

Ok, back to our goal. After we save, we need to detect if the isPublished field changed from false to true, so we can run some custom code. But by the time the state processor is called, the JSON from the user has already been used to update the object. So $data will already have isPublished true.

In the last tutorial, we had a similar situation with a validator where we needed to check if the owner of a DragonTreasure had changed. This logic lives in TreasureAllowToChangeValidator. We start with $value - which is a collection of DragonTreasure objects, loop over them, then use Doctrine's UnitOfWork to see what each DragonTreasure looked like when it was originally loaded from the database.

// ... lines 1 - 10
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator
{
// ... lines 13 - 16
public function validate($value, Constraint $constraint): void
{
// ... lines 19 - 27
$unitOfWork = $this->entityManager->getUnitOfWork();
foreach ($value as $dragonTreasure) {
// ... lines 30 - 31
$originalData = $unitOfWork->getOriginalEntityData($dragonTreasure);
$originalOwnerId = $originalData['owner_id'];
$newOwnerId = $dragonTreasure->getOwner()->getId();
if (!$originalOwnerId || $originalOwnerId === $newOwnerId) {
return;
}
// the owner is being changed
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

Should we use that same trick here to see what the isPublished property originally looked like? We could... but there's an easier way!

API Platform has a concept of "previous data". When the request starts, API Platform clones the top-level object. So, if we're editing a DragonTreasure, it grabs that from the database using our state provider, clones it and, then keeps that "original" clone around in case it comes in handy. We can use that to see if the value of isPublished changed.

But wait, why didn't we just this "previous data" thing in the last tutorial for the validator? The reason is subtle. For the validator, the top-level object was a User object. When PHP clones an object, it's a "shallow" clone: any string, int or boolean properties are copied to the clone. But any object properties - like the DragonTreasure objects - are not copied: the clone and the original User objects both point to the same DragonTreasure objects in memory. So when the owner of those treasures is updated... that affected both the main object and the "previous object" clone. That is why we had to go deeper and use UnitOfWork.

But in this case, the isPublished property is a boring scalar boolean property. So if we can get the previous data, that will have the correct, original, isPublished value.

Great! So... how do we get the previous data? Notice we're passed an argument called $context... which is full of useful info. Let's dd() that.

35 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 11
class DragonTreasureStateProcessor implements ProcessorInterface
{
// ... lines 14 - 21
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
dd($context);
// ... lines 25 - 32
}
}

Then copy the test name we're working on and... run it:

symfony php bin/phpunit --filter=testPublishTreasure

Oooo: a bunch of good stuff here. We have the current operation object... and here it is: previous_data. Check out that beautiful isPublished property: it's false!

Get rid of the dd(). At the bottom, say $previousData = $context['previous_data']. And, if it's not there - which will happen for a POST request - set it to null. I'll paste in the rest of the code that detects if isPublished changed from false to true. Actually... this is not the best code I've ever written - it's kinda confusing and won't let you publish immediately via a POST... but it'll work for our purposes. Inside, add a dump.

42 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 21
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
// ... lines 24 - 30
$previousData = $context['previous_data'] ?? null;
if ($previousData instanceof DragonTreasure
&& $data->getIsPublished()
&& $previousData->getIsPublished() !== $data->getIsPublished()
) {
dd('published!');
}
// ... lines 38 - 39
}
// ... lines 41 - 42

Let's do it! Run the test:

symfony php bin/phpunit --filter=testPublishTreasure

And... we hit the dump!

Testing for and Creating Notifications

Our project has an unused Notification entity that I created before recording just for this feature: it relates to a treasure and has a message. Nothing fancy. Let's create one of these when we publish... by first testing for it. TDD!

At the end of the test, say NotificationFactory - that's a Foundry factory that I created, ::repository() - to get a repository helper - then ->assert()->count(1).

// ... lines 1 - 14
class DragonTreasureResourceTest extends ApiTestCase
{
// ... lines 17 - 220
public function testPublishTreasure(): void
{
// ... lines 223 - 239
NotificationFactory::repository()->assert()->count(1);
}
}

With Foundry, our database is always empty at the start of a test: so checking for 1 row is perfect.

Back in the processor, remove the dd()... then check that the test fails our new assertion:

symfony php bin/phpunit --filter=testPublishTreasure

Excellent! Back over, start by autowiring a private EntityManagerInterface $entityManager. Then, below, I'll paste in some boring code that creates a Notification and persists it.

49 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 13
class DragonTreasureStateProcessor implements ProcessorInterface
{
// ... lines 16 - 24
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
// ... lines 27 - 33
$previousData = $context['previous_data'] ?? null;
if ($previousData instanceof DragonTreasure
&& $data->getIsPublished()
&& $previousData->getIsPublished() !== $data->getIsPublished()
) {
$notification = new Notification();
$notification->setDragonTreasure($data);
$notification->setMessage('Treasure has been published!');
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
// ... lines 45 - 46
}
}

Coolio. And the test says...

symfony php bin/phpunit --filter=testPublishTreasure

... that we rock! Next up: time to get crazy by creating a totally custom ApiResource class that is not an entity.