Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Data Persister Decoration

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.

In the last course, we create this UserDataPersister class:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 20
public function supports($data): bool
{
return $data instanceof User;
}
/**
* @param User $data
*/
public function persist($data)
{
if ($data->getPlainPassword()) {
$data->setPassword(
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
);
$data->eraseCredentials();
}
$this->entityManager->persist($data);
$this->entityManager->flush();
}
public function remove($data)
{
$this->entityManager->remove($data);
$this->entityManager->flush();
}
}

Um, what is a "Persister"?

Now, let's back up real quick. Whenever you use a POST or PUT endpoint, after ApiPlatform deserializes the data into an object and validates it, it tries to save or persist that object. Usually, this means that we're saving an entity object to the database via Doctrine. But we could "save" an object anywhere, like by sending the data to another API, or putting it into Redis or ElasticSearch. ApiPlatform doesn't really care. These "things that save the object" are called "data persisters".

So far, our two API resources - CheeseListing and User - are both entities... and ApiPlatform has special support for Doctrine, including a core Doctrine data persister. So whenever we make a POST, PUT or also PATCH request to an ApiResource that is an entity, that core Doctrine data persister jumps into action and saves the object to the database. This means that normally we don't need to create our own data persister: the core one calls persist() and flush() for us.

Taking Action Before/After Save

But what if you need to do something right before or after an item is saved to the database? Well, you could use a Doctrine listener for that... but if you want that code to only run in the context of your API, how can we? The answer is to create a custom data persister. For example, we created UserDataPersister because we needed to encode the plain password and set it onto the password field before saving:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
if ($data->getPlainPassword()) {
$data->setPassword(
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
);
$data->eraseCredentials();
}
$this->entityManager->persist($data);
$this->entityManager->flush();
}
... lines 41 - 46
}

To create this data persister, we implemented DataPersisterInterface and added a supports() method that says that we support User objects:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 20
public function supports($data): bool
{
return $data instanceof User;
}
... lines 25 - 46
}

As soon as we did that, we became 100% responsible for persisting User objects. What I mean is, the normal, core Doctrine data persister will no longer be called for User objects: our persister takes precedence. This is why we did our custom work up here, but then had to call persist() and flush() at the bottom:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
if ($data->getPlainPassword()) {
$data->setPassword(
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
);
$data->eraseCredentials();
}
$this->entityManager->persist($data);
$this->entityManager->flush();
}
... lines 41 - 46
}

To prove that our data persister replaces the core persister for User objects, let's comment-out the persist() call and run our tests:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
... lines 31 - 37
//$this->entityManager->persist($data);
... line 39
}
... lines 41 - 46
}

Open tests/Functional/UserResourceTest. This testCreateUser() method should now fail because the User won't actually be saved to the database:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
$client = self::createClient();
$client->request('POST', '/api/users', [
'json' => [
'email' => 'cheeseplease@example.com',
'username' => 'cheeseplease',
'password' => 'brie'
]
]);
$this->assertResponseStatusCodeSame(201);
$this->logIn($client, 'cheeseplease@example.com', 'brie');
}
... lines 26 - 73
}

Copy that method name and run:

symfony run bin/phpunit --filter=testCreateUser

And... yea! It failed! This test passed a minute ago, but now we get a 400 bad request... which happens because the un-persisted entity is missing an id... and so ApiPlatform has trouble serializing it.

If we put the persist() back:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
... lines 31 - 37
$this->entityManager->persist($data);
... line 39
}
... lines 41 - 46
}

And run the test again:

symfony run bin/phpunit --filter=testCreateUser

All better!

Decorating the Core Persister

So the fact that we are now entirely responsible for persisting the object is... not really that big of a deal. But it would be even better if we could do our custom work up here... and then just call the core data persister so that it can do its normal logic.

And that's totally possible via decoration. Yep, instead of injecting the entity manager into the constructor, replace this with DataPersisterInterface and call it, how about, $decoratedDataPersister:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
... lines 6 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 13
public function __construct(DataPersisterInterface $decoratedDataPersister, UserPasswordEncoderInterface $userPasswordEncoder)
{
... lines 16 - 17
}
... lines 19 - 43
}

Copy that and rename the variable and the $entityManager property to $decoratedDataPersister:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
... lines 6 - 8
class UserDataPersister implements DataPersisterInterface
{
private $decoratedDataPersister;
... lines 12 - 13
public function __construct(DataPersisterInterface $decoratedDataPersister, UserPasswordEncoderInterface $userPasswordEncoder)
{
$this->decoratedDataPersister = $decoratedDataPersister;
... line 17
}
... lines 19 - 43
}

This is nice because, down here, instead persist() and flush(), all we need is $this->decoratedDataPersister->persist($data):

... lines 1 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 27
public function persist($data)
{
... lines 30 - 35
return $this->decoratedDataPersister->persist($data);
}
... lines 39 - 43
}

Down in remove(), we can do the same: $this->decoratedDataPersister->remove($data):

... lines 1 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 39
public function remove($data)
{
$this->decoratedDataPersister->remove($data);
}
}

Beautiful! But... before we try this... um... don't try this unless you have Xdebug installed. Because... when I run my test:

symfony run bin/phpunit --filter=testCreateUser

Ah! It's recursion!

Maximum function nesting level of 256 reached

Ok, let's figure out what's going on. In the constructor, we just said DataPersisterInterface and Symfony, apparently, figured out what to pass us:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
... lines 6 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 13
public function __construct(DataPersisterInterface $decoratedDataPersister, UserPasswordEncoderInterface $userPasswordEncoder)
{
... lines 16 - 17
}
... lines 19 - 43
}

This means that the data persister must be passed via autowiring.

Let's go get more info about that autowired service. Run:

php bin/console debug:autowiring Persister

And... here it is! This is an alias for some service called debug.api_platform.data_persister. Ok! Let's find more info about that service:

php bin/console debug:container debug.api_platform.data_persister

Ok: the class name is TraceableChainDataPersister. The key word in the name is "chain". If we dug a few levels deeper, we would find out that the data persister service actually holds a collection of data persisters inside of it!

Basically, whenever something needs to be persisted, ApiPlatform calls persist() on this one data persister. Internally, it then loops over the persisters inside, calls supports() on each one:

... lines 1 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 19
public function supports($data): bool
{
return $data instanceof User;
}
... lines 24 - 43
}

And then calls persist() on the first persister that supports the object.

So... this is really cool! If you need the entire data persister system, you can autowire this one service and call persist() on it.

The problem is that we are effectively calling ourselves! ApiPlatform originally calls persist on the ChainDataPersister, it calls persist() on us... and then we call persist() back on the ChainDataPersister:

... lines 1 - 8
class UserDataPersister implements DataPersisterInterface
{
... lines 11 - 27
public function persist($data)
{
... lines 30 - 36
return $this->decoratedDataPersister->persist($data);
}
... lines 39 - 43
}

Whoops!

So instead of injecting the entire data persister system, let's inject and use just the one data persister that we know we want: the one that is normally used to save entities. We'll do that next and learn more about how the data persister system is different than some other parts of ApiPlatform, like the context builder system.

Leave a comment!

5
Login or Register to join the conversation
Dmitriy Avatar

Is the service ApiPlatform\Core\DataPersister\DataPersisterInterface available in Api Platform 3.0 and higher?
Symfony can't see this.

Reply

Hey @Dmitriy,

No, this service is not available it was changed to ApiPlatform\State\ProcessorInterface and works a little differently.

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": ">=8.1",
        "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
    }
}
userVoice