Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Logic Only for some Operations

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Data persisters are the way to do logic before or after something saves. But what if you want to do something only when your object is being created? Or only when it's being updated? Or maybe only when a specific field changes from one value to another? Let's talk about all of that.

Here's our first goal: log a message only when a user is created in our API. And this one is pretty simple. Start by adding a third argument to the constructor for LoggerInterface $logger. I'll hit Alt+Enter and go to "Initialize Properties" as a shortcut to create that property and set it:

... lines 1 - 6
use Psr\Log\LoggerInterface;
... lines 8 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 13
private $logger;
public function __construct(DataPersisterInterface $decoratedDataPersister, UserPasswordEncoderInterface $userPasswordEncoder, LoggerInterface $logger)
{
... lines 18 - 19
$this->logger = $logger;
}
... lines 22 - 53
}

Down in persist(), since this is an entity, we don't need to do anything fancy to determine if the object is being created versus updated. We can say: if !$data->getId():

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 30
public function persist($data)
{
if (!$data->getId()) {
... lines 34 - 37
}
... lines 39 - 47
}
... lines 49 - 53
}

Then log something: $this->logger->info(sprintf()):

User %s just registered. Eureka!

And pass $data->getEmail():

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 30
public function persist($data)
{
if (!$data->getId()) {
// take any actions needed for a new user
// send registration email
// integrate into some CRM or payment system
$this->logger->info(sprintf('User %s just registered! Eureka!', $data->getEmail()));
}
... lines 39 - 47
}
... lines 49 - 53
}

That's it! Custom code for when a user is created, which in a real app might be sending a registration email or throwing a party!

I'm guessing this will all work... but let's at least make sure we didn't break anything:

symfony php bin/phpunit --filter=testCreateUser

Custom Code only for a Specific Operation

Cool! So let's do something a bit harder: what if you need to run some custom code but only for a specific operation. In User, we have 2 collection operations and 3 item operations:

... lines 1 - 16
/**
* @ApiResource(
... lines 19 - 21
* collectionOperations={
* "get",
* "post"={
* "security"="is_granted('IS_AUTHENTICATED_ANONYMOUSLY')",
* "validation_groups"={"Default", "create"}
* },
* },
* itemOperations={
* "get",
* "put"={"security"="is_granted('ROLE_USER') and object == user"},
* "delete"={"security"="is_granted('ROLE_ADMIN')"}
* }
* )
... lines 35 - 38
*/
class User implements UserInterface
{
... lines 42 - 250
}

For the most part, by checking the id, you can pretty much figure out which operation is being used. But if you start also using a PATCH operation or custom operations, then this would not be enough.

Like many parts of ApiPlatform, a data persister has a normal interface - DataPersisterInterface:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
... lines 6 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 53
}

But also an optional, stronger interface that gives you access to extra info about what's going on.

Change the interface to ContextAwareDataPersisterInterface:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
... lines 6 - 10
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 13 - 54
}

This extends DataPersisterInterface and the difference now is that all the methods suddenly have an array $context argument. Copy that and also add it to persist() and down here on remove():

... lines 1 - 10
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 13 - 23
public function supports($data, array $context = []): bool
{
... line 26
}
... lines 28 - 31
public function persist($data, array $context = [])
{
... lines 34 - 48
}
public function remove($data, array $context = [])
{
... line 53
}
}

Beautiful! To see what's inside this array, at the top of persist, dump($context):

... lines 1 - 10
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 13 - 31
public function persist($data, array $context = [])
{
dump($context);
... lines 35 - 50
}
... lines 52 - 56
}

And then go run the test again:

symfony php bin/phpunit --filter=testCreateUser

And... there it is! It has resource_class, collection_operation_name and a few other keys. Two things about this. One, this context is not the same as the "serialization context" array that we've talked about a lot. There is some overlap, but mostly this word "context" is just being re-used. Two: the last 3 items relate to ApiPlatform's event system... and probably won't be useful here.

The truly useful item is collection_operation_name, which will be called item_operation_name for an item operation - like a PUT request. I'll run all the user tests:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

And... yep! There's a good example of both situations.

Armed with this info, we are dangerous! Back in the persister, if $context['item_operation_name'] - don't forget your $ - ?? null - to avoid an error when this key does not exist - === 'put':

... lines 1 - 10
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 13 - 31
public function persist($data, array $context = [])
{
if (($context['item_operation_name'] ?? null) === 'put') {
... line 35
}
... lines 37 - 52
}
... lines 54 - 58
}

Then this is the PUT item operation. Log User %s is being updated and pass $data->getId():

... lines 1 - 10
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 13 - 31
public function persist($data, array $context = [])
{
if (($context['item_operation_name'] ?? null) === 'put') {
$this->logger->info(sprintf('User "%s" is being updated!', $data->getId()));
}
... lines 37 - 52
}
... lines 54 - 58
}

I love it! When we run the tests now:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

They still pass! I am assuming that the logs are being written correctly... I would at least test this manually in a real app.

By using the context and the $data->getId(), we have a lot of control to execute custom code in the right situations. But things can get more complex. What if we need to run code only when a specific field changes from one value to another?

For example, our CheeseListing entity has an isPublished field, which - so far - isn't writable in our API at all:

... lines 1 - 56
class CheeseListing
{
... lines 59 - 98
/**
* @ORM\Column(type="boolean")
*/
private $isPublished = false;
... lines 103 - 214
}

Next: let's make it possible for a user to publish their listing... but also execute some code when that happens.

Leave a comment!

6
Login or Register to join the conversation
Bernard A. Avatar
Bernard A. Avatar Bernard A. | posted 6 months ago

Hi
A couple of things:
First, I work mostly with graphql on API-Platform and would appreciate if you create a specific tutorial for that.

Secondly, with API-Platform graphql, I would normally do this type of customization per/post persist on the stages.
Is there any downside of using stages as opposed to data persisters? Why use one or the other? Thanks.

Code example on src/Stages/DeserializeStage.php


....

if ($resourceClass === 'App\Entity\Ad' && $operationName === 'create') {
$deserializeObject->setUser($user);
$deserializeObject->setIsActive(true);
// when user creating ad offer, add providedCategory if not already exists
if ($deserializeObject->getIsOffer()) {
// check if user providedCategory already include ad category
if (!$user->getProvidedCategories()->contains($deserializeObject->getCategory()) ) {
$user->addProvidedCategory($deserializeObject->getCategory());
}
}
.....
Reply

Hey Bernard A. !

Nice to chat with you :). I believe the short answer is this: data persisters work for graphql or REST whereas stages are graphql only. So, if you're using graphql (caveat: I'm not very familiar with API Platform's GraphQL), there shouldn't really be any advantage or disadvantage. Frequently in the API Platform docs, they recommend solutions that "work everywhere" vs GraphQL or REST-specific solutions... just for simplicity I think :). For REST, iirc, the events system (iirc) is unique to it (and not included in GraphQL).

Cheers!

Reply
Bernard A. Avatar

Hi
Thanks for your reply! Though you elegantly sidestepped the issue of the graphql tutorial.:)

Reply

Lol, guilty! I can't remember if I did that on purpose or not! I'll add your vote to our internal list... but probably not something we would do anytime too soon. However, we will need to, at some point in the not TOO distant future, refresh our API Platform tutorials. And when we do, that'd be the time to do it.

Cheers!

1 Reply
Anton B. Avatar
Anton B. Avatar Anton B. | posted 1 year ago

@weaverryan can you please answer this question?
https://stackoverflow.com/q...

Reply

Hey Anton!

Hmm, yea, I’m not sure! It sounds like the key is security, since you mentioned that removing a role fixes it. The question would be: can you get it to work with zero security? And can you also successfully trigger a 403 by using a role your user doesn’t have? You want to first see if either the success response or 403 response is causing the problem.

Cheers!

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