Buy Access to Course
05.

Simpler State Processor

|

Share this awesome video!

|

Publishing a DragonTreasure is easy: make a Patch request to the treasure endpoint with isPublished set to true and... celebration! But... what if, when a DragonTreasure is published, we need to run some custom code - maybe trigger some notifications on the site.

One option is to create a custom operation - like maybe POST /api/treasures/5/publish. You can do that - and it might be fun to look at in a future tutorial. But who wants extra work? We can keep that simple Patch request and still run the code that we want. How? By using a state processor and detecting the change.

Let's start by creating a test that publishes a treasure. At the bottom, copy this last test, paste, and rename it testPublishTreasure. We start with a user that owns a treasure with isPublished false. Then we log in as that user, make a ->patch() request to /api/treasures/ using the id... and send isPublished: true. This should be a 200 status code... and then ->assertJsonMatches() that isPublished is true.

// ... lines 1 - 13
class DragonTreasureResourceTest extends ApiTestCase
{
// ... lines 16 - 219
public function testPublishTreasure(): void
{
$user = UserFactory::createOne();
$treasure = DragonTreasureFactory::createOne([
'owner' => $user,
'isPublished' => false,
]);
$this->browser()
->actingAs($user)
->patch('/api/treasures/'.$treasure->getId(), [
'json' => [
'isPublished' => true,
],
])
->assertStatus(200)
->assertJsonMatches('isPublished', true)
;
}
}

Simple enough! Copy that test name, spin over and run it:

symfony php bin/phpunit --filter=testPublishTreasure

Whoops! It fails: expected false to be the same as true. That's from the last line: the JSON still has isPublished false. Maybe... the field isn't writable? Check the groups above that property. Ah: in a previous tutorial, we made this field writable by admin users, but not normal users. Add treasure:write.

275 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 90
class DragonTreasure
{
// ... lines 93 - 130
#[Groups(['admin:read', 'admin:write', 'owner:read', 'treasure:write'])]
private bool $isPublished = false;
// ... lines 133 - 273
}

That means anyone with access to the Patch operation can write to this field... which in reality, thanks to the security on that operation... and a custom voter we created... is just admin users and the owner.

275 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 30
#[ApiResource(
// ... lines 32 - 33
operations: [
// ... lines 35 - 43
new Patch(
security: 'is_granted("EDIT", object)',
),
// ... lines 47 - 49
],
// ... lines 51 - 68
)]
// ... lines 70 - 90
class DragonTreasure
// ... lines 92 - 275

Try the test now:

symfony php bin/phpunit --filter=testPublishTreasure

Got it! To run some code when the treasure is published, we need a state processor. And we already have one for `DragonTreasure! We originally created it to set the owner to the currently authenticated user. So... should we jam the new code into here or create a second processor?

It's up to you, but I like to have one processor per resource class. It just makes my life simpler. But let's rename this class to be more clear: DragonTreasureStateProcessor.

Changing How Our State Processor Decorates

In the last tutorial, we learned that there are two ways to add a custom state provider or processor into the system. We used the first method a few minutes ago with the state provider: create a normal boring service... use #[Autowire] to inject the core services... then set the provider option on DragonTreasure to point to it.

The other way - which we did in the last tutorial for this class - is to decorate the core processor. Here, we decorated the PersistProcessor from Doctrine... which means that whenever any API resource is saved, when it tries to use the core PersistProcessor, our service is called instead. This was easy to set up because all we needed was #[AsDecorator] and... bam! Our service started being called for all our resources. But that's also why we need this extra code that checks which object is being saved.

31 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 10
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
class DragonTreasureStateProcessor implements ProcessorInterface
{
// ... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if ($data instanceof DragonTreasure && $data->getOwner() === null && $this->security->getUser()) {
$data->setOwner($this->security->getUser());
}
// ... lines 23 - 28
}
}

Both ways are fine. But for consistency with the provider, let's refactor this to use the other method. This is 3 steps. First, remove #[AsDecorator]. Suddenly, instead of overriding a core service, this is a normal, boring service that nobody is using at the moment. Second, because we're no longer decorating a core service, Symfony won't know what to pass for $innerProcessor. Break this onto multiple lines... then use the #[Autowire] trick to point to the core PersistProcessor. And I'll clean up the old use statement.

35 lines | src/State/DragonTreasureStateProcessor.php
// ... lines 1 - 11
class DragonTreasureStateProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: PersistProcessor::class)]
private ProcessorInterface $innerProcessor,
private Security $security
)
{
}
// ... lines 21 - 33
}

Step 3 is to tell API Platform when to use this processor. In DragonTreasure, we want this to be used for both our Post and Patch operations. Set processor to DragonTreasureStateProcessor::class... and repeat that down for Patch.

278 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 19
use App\State\DragonTreasureStateProcessor;
// ... lines 21 - 31
#[ApiResource(
// ... lines 33 - 34
operations: [
// ... lines 36 - 41
new Post(
// ... line 43
processor: DragonTreasureStateProcessor::class,
),
new Patch(
// ... line 47
processor: DragonTreasureStateProcessor::class,
),
// ... lines 50 - 71
)]
// ... lines 73 - 93
class DragonTreasure
// ... lines 95 - 278

Done! API Platform will call our processor... and it contains the core PersistProcessor so we can make it do the real work. Re-run the test to give us infinite confidence:

symfony php bin/phpunit --filter=testPublishTreasure

That feels great.

And the nice thing about doing the processor with this method is that you don't need this conditional code: this will always be a DragonTreasure. To help my editor and prove it, assert() that $data is an instanceof DragonTreasure.

32 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 = []): void
{
assert($data instanceof DragonTreasure);
// ... lines 25 - 29
}
}

And my editor is already yelling:

Hey this code down here isn't needed anymore dude!

So, remove that too. Now that we've refactored our state processor, let's get back to the task at hand: running custom code when a treasure becomes published.