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
... 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()) {
... 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()) {
... 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
... lines 10 - 41
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!

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