Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Detecting the "Published" State Change

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Here's the plan: whenever a CheeseListing becomes published, we need to create a notification that is sent to certain users... maybe some users have subscribed to hear about all the latest new cheeses. To help, sort of, "fake" this system, I've created a CheeseNotification entity:

... lines 1 - 10
class CheeseNotification
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=CheeseListing::class)
* @ORM\JoinColumn(nullable=false)
*/
private $cheeseListing;
/**
* @ORM\Column(type="string", length=255)
*/
private $notificationText;
public function __construct(CheeseListing $cheeseListing, string $notificationText)
{
$this->cheeseListing = $cheeseListing;
$this->notificationText = $notificationText;
}
... lines 36 - 51
}

The goal is to insert a new record into this table each time a CheeseListing is published. It's not a real notification system... but it'll be an easy way for us to figure out how to run custom code in just the right situation.

Asserting the Database has Notifications

Let's start - like usual - with a test! We want to assert that after we send the PUT request, there is exactly one CheeseNotification in the database. Now, thanks to Foundry - the library that helps us insert dummy data both in our data fixtures and in our tests - before each test case, our database is emptied. That will make it very easy to count the number of rows inside the the table. Oh, and if you love this "database resetting" feature of Foundry and want a performance boost in your tests, check out DAMADoctrineTestBundle.

Anyways, thanks to another cool feature from Foundry, doing this count is easy: CheeseNotificationFactory::repository()->assertCount(1).

How nice is that? A dead-simple way to count the records in a table. I'll even give this a nice message in case it fails:

There should be one notification about being published

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
... lines 12 - 70
public function testPublishCheeseListing()
{
... lines 73 - 84
$cheeseListing->refresh();
$this->assertTrue($cheeseListing->getIsPublished());
CheeseNotificationFactory::repository()->assertCount(1, 'There should be one notification about being published');
... lines 88 - 93
}
... lines 95 - 146
}

I love it! But before we try this... and make sure it fails, let's go one step further. Copy the PUT call from up here and paste it below... because we want to make sure that if we publish the same listing again, it should not create a second notification because... we're not really publishing it again. We're... not doing anything with this second call!

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
... lines 12 - 70
public function testPublishCheeseListing()
{
... lines 73 - 86
CheeseNotificationFactory::repository()->assertCount(1, 'There should be one notification about being published');
// publishing again should not create a second notification
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
... line 93
}
... lines 95 - 146
}

Use the same assert: CheeseNotificationFactory::repository()->assertCount(1):

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
... lines 12 - 70
public function testPublishCheeseListing()
{
... lines 73 - 86
CheeseNotificationFactory::repository()->assertCount(1, 'There should be one notification about being published');
// publishing again should not create a second notification
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
CheeseNotificationFactory::repository()->assertCount(1);
}
... lines 95 - 146
}

Ok! Let's try it! Find your terminal and run just this test:

symfony php bin/phpunit --filter=testPublishCheeseListing

And... failed asserting that 0 is identical to 1:

There should be one notification about being published.

Creating the Data Persister

Ok, so... how can we accomplish this? Well, we know that we need to run some code right before or after the CheeseListing is saved. That means that we need a custom data persister. Cool! We've done that before with UserDataPersister!

Create a new PHP class and call it CheeseListingDataPersister:

... lines 1 - 2
namespace App\DataPersister;
... lines 4 - 6
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 9 - 19
}

Poetry. Let's keep this as simple as possible: implement the normal DataPersisterInterface:

... lines 1 - 2
namespace App\DataPersister;
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 9 - 19
}

Go to "Code"->"Generate" - or Command+N on a Mac - select "Implement Methods" and add the three method that we need:

... lines 1 - 6
class CheeseListingDataPersister implements DataPersisterInterface
{
public function supports($data): bool
{
}
public function persist($data)
{
}
public function remove($data)
{
}
}

Before we fill in the code, let's immediately inject the doctrine data persister so that we can use it to do the actual saving. Add public function __construct() with DataPersisterInterface $decoratedDataPersister:

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 9 - 10
public function __construct(DataPersisterInterface $decoratedDataPersister)
{
... line 13
}
... lines 15 - 26
}

I'll hit Alt+Enter and use "Initialize properties" to add that property and set it in the method:

... lines 1 - 6
class CheeseListingDataPersister implements DataPersisterInterface
{
private $decoratedDataPersister;
public function __construct(DataPersisterInterface $decoratedDataPersister)
{
$this->decoratedDataPersister = $decoratedDataPersister;
}
... lines 15 - 26
}

Down in supports()... wait. I almost forgot! We need to make sure the right data persister is passed to us. Open config/services.yaml and we can just copy the UserDataPersister service and change it to CheeseListingDatePersister, because we need the same doctrine data persister service:

... lines 1 - 7
services:
... lines 9 - 43
App\DataPersister\CheeseListingDataPersister:
bind:
$decoratedDataPersister: '@api_platform.doctrine.orm.data_persister'

Ok, back in the persister, the supports() method is easy: return $data instanceof CheeseListing:

... lines 1 - 5
use App\Entity\CheeseListing;
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 10 - 16
public function supports($data): bool
{
return $data instanceof CheeseListing;
}
... lines 21 - 30
}

Yep, if a CheeseListing is being saved, we want to handle it. In persist(), call $this->decoratedDataPersister->persist($data):

... lines 1 - 7
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 10 - 21
public function persist($data)
{
return $this->decoratedDataPersister->persist($data);
}
... lines 26 - 30
}

And in remove(), $this->decoratedDataPersister->remove($data):

... lines 1 - 7
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 10 - 26
public function remove($data)
{
return $this->decoratedDataPersister->remove($data);
}
}

Congratulations! We just created a data persister that does... the exact same thing as before. We can prove it by running the test:

symfony php bin/phpunit --filter=testPublishCheeseListing

And enjoying the exact same failure! There are still no notifications.

Fetching the Original Data

Back in persist(), the question is: how can we detect if the item was just published? I'll add a little PHPDoc above the method to help my editor: we know that $data will definitely be a CheeseListing object:

... lines 1 - 7
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 10 - 21
/**
* @param CheeseListing $data
*/
public function persist($data)
{
... line 27
}
... lines 29 - 33
}

So, the easiest thing to do would be to say: if $data->isPublished(), then... the listing was just published! Right?

... lines 1 - 7
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 10 - 24
public function persist($data)
{
if ($data->getIsPublished()) {
// hmm, not enough to know that it was JUST published
}
... lines 30 - 31
}
... lines 33 - 37
}

I'm sure you see the problem: that would tell us that the listing is published... but not that it was necessarily just published... it may have already been published... and the user was just changing the description or something. We simply don't have enough information.

What we really need is access to the original data: the way it looked before it was changed by ApiPlatform. If we had that original data, we could compare that with the current data and determine if the isPublished field did in fact change from false to true. But... how can we get that?

Actually, thanks to Doctrine, this is easy! Doctrine already keeps track of the original data: the data that came back when it originally queried the database for the CheeseListing!

To get the original data, we need the entity manager. Add a second argument to the constructor: EntityManagerInterface $entityManager. Hit Alt+Enter to initialize that new property:

... lines 1 - 6
use Doctrine\ORM\EntityManagerInterface;
class CheeseListingDataPersister implements DataPersisterInterface
{
... line 11
private $entityManager;
public function __construct(DataPersisterInterface $decoratedDataPersister, EntityManagerInterface $entityManager)
{
... line 16
$this->entityManager = $entityManager;
}
... lines 19 - 42
}

Then, in persist(), we can say $originalData = $this->entityManager->getUnitOfWork() - that's a very core object in Doctrine - ->getOriginalEntityData() and pass it $data:

... lines 1 - 8
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 11 - 27
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
... lines 31 - 36
}
... lines 38 - 42
}

How cool is that? Let's dump that $originalData to see what it looks like:

... lines 1 - 8
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 11 - 27
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
dump($originalData);
... lines 32 - 36
}
... lines 38 - 42
}

Let's go! Run the test:

symfony php bin/phpunit --filter=testPublishCheeseListing

And... cool, cool, cool! It's an array where each key represents a field of original data. It shows isPublished was false at the start. We are so dangerous now.

Using the Original Data

Remove the dump and add a new variable: $wasAlreadyPublished set to $originalData['isPublished'] ?? false:

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
$wasAlreadyPublished = ($originalData['isPublished'] ?? false);
... lines 33 - 39
}
... lines 41 - 45
}

Because if this is a new entity, $originalData will an empty array. And if this is a new CheeseListing, then it was - of course - not already published.

Finally, if $data->getIsPublished() and not $wasAlreadyPublished, we know that it was - in fact - just published!

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
$wasAlreadyPublished = ($originalData['isPublished'] ?? false);
if ($data->getIsPublished() && !$wasAlreadyPublished) {
... lines 34 - 36
}
... lines 38 - 39
}
... lines 41 - 45
}

So let's create a new notification: $notification = new CheeseNotification() and its constructor accepts the related CheeseListing object and the notification text:

Cheese listing was created!

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
$wasAlreadyPublished = ($originalData['isPublished'] ?? false);
if ($data->getIsPublished() && !$wasAlreadyPublished) {
$notification = new CheeseNotification($data, 'Cheese listing was created!');
... lines 35 - 36
}
... lines 38 - 39
}
... lines 41 - 45
}

Oh, and we need to save this to the database: $this->entityManager->persist($notification) and $this->entityManager->flush():

... lines 1 - 9
class CheeseListingDataPersister implements DataPersisterInterface
{
... lines 12 - 28
public function persist($data)
{
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
$wasAlreadyPublished = ($originalData['isPublished'] ?? false);
if ($data->getIsPublished() && !$wasAlreadyPublished) {
$notification = new CheeseNotification($data, 'Cheese listing was created!');
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
return $this->decoratedDataPersister->persist($data);
}
... lines 41 - 45
}

And yes, you could skip the flush() because, a few lines later, the Doctrine data persister will call that for us. We're... technically saving both the notification and the CheeseListing on this line... but it doesn't really matter.

So... did we do it? Let's find out! At your terminal, go test go!

symfony php bin/phpunit --filter=testPublishCheeseListing

It passes! Woo!

This "original data" trick is really the key to doing custom things on a simple, RESTful state change, like isPublished going from false to true.

Next: let's use this same trick to accomplish something else. Let's add complex rules around who can publish a CheeseListing and under what conditions. Like... maybe the owner can publish the listing... but maybe only if the description is longer than 100 characters... except that an admin can always publish... even if the description is too short. Yikes! Typical, awesome, confusing business logic. Let's crush it next!

Leave a comment!

4
Login or Register to join the conversation
Anton B. Avatar
Anton B. Avatar Anton B. | posted 2 years ago | edited

there and what about the filtering for non-mapped properties, can you help me with that?How can I filter the collection based on the value of non mapped property?E.g. property is a boolean field

Reply

Hey Anton B.!

Sorry for the slow reply - I had some time off this week :). Are you talking about ApiFilters? Lime being able to have a ?published=0 query parameter to filter the collection? I've never tried it, but based on your question, I'm guessing that the normal BooleanFilter doesn't work for non-mapped properties? https://symfonycasts.com/sc...

If that does not work for non-mapped properties, then I think you would need to create your own, custom filter: https://symfonycasts.com/sc...

I hope that helps!

Cheers!

Reply
Ali K. Avatar

why we need to put logic on datapersister instead of event subscriber with interface ? for example for hash password we can implement HashPassword interface to user entity and get it in event subscriber .

Reply

Hey Ali K.!

Excellent question :). I assume that you're thinking about a listener on kernel.view with a PRE_WRITE priority (so that it happens right before the data persister is called). I don't see any problem with that. The data persister solution is preferred over listeners because listeners only happen for the REST API and don't work the same for the GraphQL implementation... but that probably isn't a problem in most situations :).

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