Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Auto-set the Owner: Entity Listener

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

We decided to make the owner property a field that an API client must send when creating a CheeseListing. That gives us some flexibility: an admin user can send this field set to any User, which might be handy for a future admin section. To make sure the owner is valid, we've added a custom validator that even has an edge-case that allows admin users to do this.

But the most common use-case - when a normal user wants to create a CheeseListing under their own account - is a bit annoying: they're forced to pass the owner field... but it must be set to their own user's IRI. That's perfectly explicit and straightforward. But... couldn't we make life easier by automatically setting owner to the currently-authenticated user if that field isn't sent?

Let's try it... but start by doing this in our test... which will be a tiny change. When we send a POST request to /api/cheeses with title, description and price, we expect it to return a 400 error because we forgot to send the owner field. Let's change this to expect a 201 status code. Once we finish this feature, only sending title, description and price will work.

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 13
public function testCreateCheeseListing()
{
... lines 16 - 33
$this->assertResponseStatusCodeSame(201);
... lines 35 - 44
}
... lines 46 - 74
}

To start, take off the NotBlank constraint from $owner - we definitely don't want it to be required anymore.

... lines 1 - 48
class CheeseListing
{
... lines 51 - 95
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
* @Groups({"cheese:read", "cheese:collection:post"})
* @IsValidOwner()
*/
private $owner;
... lines 103 - 206
}

If we run the tests now...

php bin/phpunit --filter=testCreateCheeseListing

Yep! It fails... we're getting a 500 error because it's trying to insert into cheese_listing with an owner_id that is null.

How to Automatically set the Field?

So, how can we automatically set the owner on a CheeseListing if it's not already set? We have a few options! Which in programming... is almost never a good thing. Hmm. Don't worry, I'll tell you which one I would use and why.

Our options include an API Platform event listener - a topic we haven't talked about yet - an API Platform data persister or a Doctrine event listener. The first two - an API Platform event listener or data persister - have the same possible downside: the owner would only be automatically set when a CheeseListing is created through the API. Depending on what you're trying to accomplish, that might be exactly what you want - you may want this magic to only affect your API operations.

But... in general... if I save a CheeseListing - no matter if it's being saved as part of an API call or in some other part of my system - and the owner is null, I think automatically setting the owner makes sense. So, instead of making this feature only work for our API endpoints, let's use a Doctrine event listener and make it work everywhere.

Event Listener vs Entity Listener

To set this via Doctrine, we can create an event listener or an "entity" listener... which are basically two, effectively identical ways to run some code before or after an entity is saved, updated or deleted. We'll use an "entity" listener.

In the src/ directory, create a Doctrine/ directory... though, like usual, the name of the directory and class doesn't matter. Put a new class inside called, how about, CheeseListingSetOwnerListener. This will be an "entity listener": a class with one or more functions that Doctrine will call before or after certain things happen to a specific entity. In our case, we want to run some code before a CheeseListing is created. That's called "pre persist" in Doctrine. Add public function prePersist() with a CheeseListing argument.

... lines 1 - 4
use App\Entity\CheeseListing;
class CheeseListingSetOwnerListener
{
public function prePersist(CheeseListing $cheeseListing)
{
}
}

Two things about this. First, the name of this method is important: Doctrine will look at all the public functions in this class and use the names to determine which methods should be called when. Calling this prePersist() will mean that Doctrine will call us before persisting - i.e. inserting - a CheeseListing. You can also add other methods like postPersist(), preUpdate() or preRemove().

@ORM\EntityListeners Annotation

Second, this method will only be called when a CheeseListing is being saved. How does Doctrine know to only call this entity listener for cheese listings? Well, it doesn't happen magically thanks to the type-hint. Nope, to hook all of this up, we need to add some config to the CheeseListing entity. At the top, add a new annotation. Actually... let's reorganize the annotations first... and move @ORM\Entity to the bottom... so it's not mixed up in the middle of all the API Platform stuff. Now add @ORM\EntityListeners() and pass this an array with one item inside: the full class name of the entity listener class: App\Doctrine\... and then I'll get lazy and copy the class name: CheeseListingSetOwnerListener.

... lines 1 - 17
/**
... lines 19 - 47
* @ORM\EntityListeners({"App\Doctrine\CheeseListingSetOwnerListener"})
*/
class CheeseListing
... lines 51 - 209

That's it for the basic setup! Thanks to this annotation and the method being called prePersist(), Doctrine will automatically call this before it persists - meaning inserts - a new CheeseListing.

Entity Listener Logic

The logic for setting the owner is pretty simple! To find the currently-authenticated user, add an __construct() method, type-hint the Security service and then press Alt + Enter and select "Initialize fields" to create that property and set it.

... lines 1 - 5
use Symfony\Component\Security\Core\Security;
... line 7
class CheeseListingSetOwnerListener
{
private $security;
... line 11
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 16 - 26
}

Next, inside the method, start by seeing if the owner was already set: if $cheeseListing->getOwner(), just return: we don't want to override that.

... lines 1 - 16
public function prePersist(CheeseListing $cheeseListing)
{
if ($cheeseListing->getOwner()) {
return;
}
... lines 22 - 25
}
... lines 27 - 28

Then if $this->security->getUser() - so if there is a currently-authenticated User, call $cheeseListing->setOwner($this->security->getUser()).

... lines 1 - 16
public function prePersist(CheeseListing $cheeseListing)
{
if ($cheeseListing->getOwner()) {
... lines 20 - 22
if ($this->security->getUser()) {
$cheeseListing->setOwner($this->security->getUser());
}
}
... lines 27 - 28

Cool! Go tests go!

php bin/phpunit --filter=testCreateCheeseListing

And... it passes! I'm kidding... that exploded. Hmm, it says:

Too few arguments to CheeseListingSetOwnerListener::__construct() 0 passed.

Huh. Who's instantiating that class? Usually in Symfony, we expect any "service class" - any class that's not a simple data-holding object like our entities - to be instantiated by Symfony's container. That's important because Symfony's container is responsible for all the autowiring magic.

But... if you look at the stack trace... it looks like Doctrine itself is trying to instantiate the class. Why is Doctrine trying to create this object instead of asking the container for it?

Tagging the Service

The answer is... that's... sort of... just how it works? Um, ok, better explanation. When used as an independent library, Doctrine typically handles instantiating these "entity listener" classes itself. However, when integrated with Symfony, you can tell Doctrine to instead fetch that service from the container. But... you need a little bit of extra config.

Open config/services.yaml and override the automatically-registered service definition: App\Doctrine\ and go grab the CheeseListingSetOwnerListener class name again. We're doing this so that we can add a little bit of extra service configuration. Specifically, we need to add a tag called doctrine.orm.entity_listener.

... lines 1 - 8
services:
... lines 10 - 41
App\Doctrine\CheeseListingSetOwnerListener:
tags: [doctrine.orm.entity_listener]

This says:

Hey Doctrine! This service is an entity listener. So when you need the CheeseListingSetOwnerListener object to do the entity listener stuff, use this service instead of trying to instantiate it yourself.

And that will let Symfony do its normal, autowiring logic. Try the test one last time:

php bin/phpunit --filter=testCreateCheeseListing

And... we're good! We've got the best of all worlds! The flexibility for an API client to send the owner property, validation when they do, and an automatic fallback if they don't.

Next, let's talk about the last big piece of access control: filtering a collection result to only the items that an API client should see. For example, when we make a GET request to /api/cheeses, we should probably not return unpublished cheese listings... unless you're an admin.

Leave a comment!

17
Login or Register to join the conversation

Hey there :) amazing cast. Thank yu!
Small question, why when we update an entity ! The prePersist is not fired, instead it's the preUpdated callback who is called ?

Reply

Hey ahmedbhs

I'm glad to hear you are liking our content. About your question, the prePersist event is only fired when a new entity is saved in the database for the first time. And, the preUpdate event is fired every time an entity is updated in the database, that's just how Doctrine events works.

Cheers!

Reply
Mees E. Avatar

Hi,

I'm a bit confused on this part. Doesn't this cuase validation errors? For example I want to check if the userID is unique. So I use the @UniqueEntity("userID") annotation. This doesn't seem to trigger at all. The same goes for the @IsValidOwner() annotation. It just throws a 500 error because of the unique SQL key. Is there a solution for this. For example add the userID in a earlier state (before the validation)?

Hope you guys can help!
Thanks!

Reply

Hey Mees E.!

Yes, actually. The listener is running AFTER validation, so we're setting the owner and it's saving, without any validation. For our example, that works fine: we don't need to run IsValidOwner() because that checks to see if the owner === the current user, for when we're doing an edit. But for a create, it's always valid to set the owner to the currently-authenticated user. So no validation needed. And for UniqueEntity, it's just not something we need here.

> For example I want to check if the userID is unique

Let's see if we can figure this out :). One option would be to use the listener, and then execute validation yourself. Do that by injecting the ValidatorInterface service and calling validate. That will return a ConstraintViolationList. If that has any validations errors in it, throw the special ValidationException. You're basically repeating what Api Platform does for its own validation layer: https://github.com/api-plat...

Let me know if that helps!

Cheers!

1 Reply
Mees E. Avatar

Thank you for responding! That should work!

Just for extra information: Are there any best practices on validating OneToOne relations without throwing a 500 error? This solution should work but does sound like there's a better option.

Thanks!

Reply

Hey Mees E.!

Hmm. In general, I think UniqueEntity should work for this... I can't think of a reason why it wouldn't. BUT, that's assuming that you aren't using a listener to set the owner like this: that assumes that you are just allowing the user to send data, and you want to guarantee that there isn't already a record in the database with the same user. I could be wrong, as I rarely use OneToOne, but I think that in the "normal data setting situation", UniqueEntity should catch it.

Cheers!

1 Reply

I have to add : private ?User $owner = null; into CheeseListing class to succeed the test.

Reply

Hey Stephane,

If you want to use that new PHP property types feature - yes, you have to :) It's tricky because we need to allow putting the entity into invalid state to validate it. Thanks for sharing your solution with others

Cheers!

Reply
André P. Avatar
André P. Avatar André P. | posted 1 year ago

I was thinking, in cases like this (when persisting data), is there a difference between using an Entity Listener or a Data Persister?

To my understanding, a Data Persister is only executed when using API Platform and an Entity Listener is always executed be it with API Platform or not. Am I thinking right?

So, if that was not an issue I could use a Data Persister to auto-set the owner the same way I could use an Entity Listener to encode a password?

Thanks!

Reply

Hey André P.

Yes, you're right. The DataPersister is a layer inside ApiPlatform, so, if you save an entity out of ApiPlatform, as far as I know, its DataPersister won't be called. My recomendation here is to use your API to create your entities, instead of having 2 different ways for creating those, it avoids code duplication and may simplify your logic

Cheers!

1 Reply
Default user avatar

Hi,
if i want to set the owner of multiple entities, should i duplicate my listener for each entity or there is a smart way to do this?
Thanks for this great course.

Reply

Hey @adam!

Excellent question :). No need to duplicate the listener. What you can do is this:

A) Create an interface with a setOwner(User $user) method. Make all your entities that need this "set owner functionality" have this. Maybe the interface is called "SettableOwnerInterface".

B) Create an entity listener just like in this video, but use the SettableOwnerInterface type-hint on the prePersist() argument. Because, once you're done, this method will receive one of multiple different objects.

C) Register your entity listener on all the entity classes that you need.

That should do it! Btw, if you used a Doctrine event subscriber that's called on prePersist() of *all* entities, then you could skip step (C) and instead just check of the entity being passed to your method is an instance of SettableOwnerInterface.

Let me know if you have any questions!

Cheers!

Reply
Fox C. Avatar

This more advanced symfony content is great. The non-screencast visuals / presentation stuff for covering theory (even text) is a really helpful comprehension aide too.

Reply

Hi Cameron,

Thank you for your feedback!

Cheers!

1 Reply
Wannes V. Avatar
Wannes V. Avatar Wannes V. | posted 2 years ago

Hi There!
I have A question about the entitylistener, but first: please tell me this is not the right place for this question since its maybe already a bit too much off-topic :)

Anyways lets give it a try!:
I use the doctrine entitylistener as I learned a whilo ago from this video. Only difference, I use postPersist.

I am now experimenting a bit with phpmailer since i want to sent a mail to the user right after he registered for the first time. Everything kind of works since my mail is received and data is persisted but somehow this also seems to mess with the response I'm getting from my API... as soon as I add 'mail->send()' the data in my response is no longer the one from my entity but is all info from my mailserver etc.. Probably I'm breaking some rules here of which I don't know but so far I havent found anything online

All the best,
wannes!


//function to be executed after persisting to the database
class AfterRegistrationEmailListener
{
public function postPersist ()
{
$mail = new PHPMailer;
$mail->isSMTP();
$mail->SMTPDebug = SMTP::DEBUG_SERVER;
$mail->Host = 'smtp.gmail.com';
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = true;

$mail->Username = $_SERVER['EMAILADRESS'];
$mail->Password = $_SERVER['PASSWORDMAIL'];

$mail->setFrom('wannes@mail.com', 'wannes');
$mail->addReplyTo('wannes@hotmail.com', 'wannes');
$mail->addAddress('john@mail.com', 'John Doe');
$mail->Subject = 'PHPMailer GMail SMTP test';
$mail->msgHTML("hi there!");

$mail->send();

return;
}
}

Reply
Wannes V. Avatar

Oh well I by now found out it was a problem related to phpmailer, not to my entitylistener so not so much related to the course!

In case anyone ever comes by this question with a similar issue:

The debug server of php mailer injected data in my response, malforming my entire response. simply removing the debugserver made everything work as expected :)

Reply

Hey Wannes,

Glad you were able to solve this problem yourself, well done! And thank you for sharing your solution with others!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}