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!

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