If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHere'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.
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.
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.
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.
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!
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!
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 .
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!
// 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
}
}
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