Doctrine postLoad 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

Let's create another custom field on User. I know, we're getting crazy, but I want to keep seeing how far we can push API Platform's abilities.

This new field will be similar to isMe... but just different enough that it will lead us to an alternate solution. Suppose some users are MVPs... but being an MVP isn't as simple as having a boolean isMvp column in the database. Nope, we need to use a super fancy calculation that requires a service to determine if a User is an MVP.

So here's our goal: expose a new isMvp field to our API whose value will be populated via custom logic in a service.

Creating the isMvp Property

To start, you know the drill, we're going to add this as a real property in our entity but not persist it to Doctrine. Call it $isMvp and let's add some documentation that can be used by the API docs:

Returns true if this user is an MVP

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 102
/**
* Returns true if this user is an MVP
*
* @Groups({"user:read"})
*/
private $isMvp = false;
... lines 109 - 284
}

Next, down at the bottom, go to "Code"->"Generate" - or Command+N on a Mac - and generate the getter and setter:

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 275
public function isMvp(): bool
{
return $this->isMvp;
}
public function setIsMvp(bool $isMvp)
{
$this->isMvp = $isMvp;
}
}

But rename the method to getIsMvp():

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 275
public function getIsMvp(): bool
{
... line 278
}
... lines 280 - 284
}

The property-access component does support looking for "isser" methods... but for that to work, the property would have to be called just mvp to look for an isMvp() method. Because our property is called isMvp, we need a getIsMvp() method.

Anyways, now if we move over and go to /api, we're hoping that the new field will already be in the docs. If you look under the user endpoint and go to schema... boom! An isMvp boolean field.

Testing the Behavior

Of course, that field will always be false because we're never setting it. That's our next job. Well, let's start by describing the behavior in a test.

In our app, a user will be an MVP if their username - which is a field on User - contains the word "cheese":

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 68
/**
* @ORM\Column(type="string", length=255, unique=true)
* @Groups({"user:read", "user:write", "cheese:item:get"})
* @Assert\NotBlank()
*/
private $username;
... lines 75 - 284
}

Okay, in reality, we could accomplish this with just a custom getter method that looks for cheese in the $username property and returns true or false. We don't actually need a service... but let's pretend we do.

In UserResourceTest, scroll down to testGetUser():

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 47
public function testGetUser()
{
$client = self::createClient();
$user = UserFactory::new()->create(['phoneNumber' => '555.123.4567']);
$authenticatedUser = UserFactory::new()->create();
... lines 53 - 77
}
}

When we create this user, we pass it a phoneNumber, but the rest of the fields come from the UserFactory class - from the getDefaults() method:

... lines 1 - 19
final class UserFactory extends ModelFactory
{
... lines 22 - 23
protected function getDefaults(): array
{
return [
'email' => self::faker()->email,
'username' => self::faker()->userName,
// hashed version of "test"
// php bin/console security:encode-password --env=test
'password' => '$argon2id$v=19$m=10,t=3,p=1$eyXPWiQFWUO901E78Bb3UQ$hyu9dFDz7fo2opQyCSoX/NfJDvEpzER/a+WbiAagqqw',
];
}
... lines 34 - 46
}

Yep, it's using a random username from Faker.

Now, let's be a bit more specific: pass username set to cheesehead:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 47
public function testGetUser()
{
... line 50
$user = UserFactory::new()->create([
'phoneNumber' => '555.123.4567',
'username' => 'cheesehead',
]);
... lines 55 - 81
}
}

A few lines later, we make a request to GET this user, assert that the response is 200 and check for the username in assertJsonContains(). Let's also check for isMvp set to true:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 47
public function testGetUser()
{
... lines 50 - 77
$this->assertJsonContains([
'phoneNumber' => '555.123.4567',
'isMe' => true,
]);
}
}

That's good enough for now! Copy the method name and run that test:

symfony php bin/phpunit --filter=testGetUser

And... it fails! Woo!

Creating the Entity Listener

To set this field, we already know two solutions: a custom data provider or an event listener that sets the field early in the request.

But neither of those options will set this value everywhere. The data provider is only used for API requests and the event subscriber won't set the field inside the CLI - like when running a command.

And maybe that's okay! But this last solution will work everywhere: a Doctrine "postLoad" listener: a function that is called each time an entity is loaded from the database.

The only downside to this solution is that the postLoad listener is always called when you query for an object... even if you'll never use the isMvp field. Though... I'll show you a trick to get around this.

To create a Doctrine listener, well, you can technically create a Doctrine event subscriber, listener or something called an entity listener... and they all basically work the same.

In part 2 of this series, we created an entity listener... and I kind of like those. In src/Doctrine, we created this entity listener to automatically set the owner field to the currently authenticated User for cheese listings:

... lines 1 - 7
class CheeseListingSetOwnerListener
{
... lines 10 - 16
public function prePersist(CheeseListing $cheeseListing)
{
if ($cheeseListing->getOwner()) {
return;
}
if ($this->security->getUser()) {
$cheeseListing->setOwner($this->security->getUser());
}
}
}

Anyways, create a new PHP class and call it UserSetIsMvpListener:

... lines 1 - 2
namespace App\Doctrine;
... lines 4 - 6
class UserSetIsMvpListener
{
... lines 9 - 12
}

Listeners don't extend anything: the only rule is that you need to have a public function with the name of the event that you want to listen to. So postLoad() with a User $user argument since we'll hook this up as an entity listener for that class:

... lines 1 - 4
use App\Entity\User;
class UserSetIsMvpListener
{
public function postLoad(User $user)
{
... line 11
}
}

We'll do that in a minute.

First, let's finish the logic: $user->setIsMvp() with strpos() to look inside of $user->getUsername() for the string cheese. If this is not false, then this user is an MVP:

... lines 1 - 4
use App\Entity\User;
class UserSetIsMvpListener
{
public function postLoad(User $user)
{
$user->setIsMvp(strpos($user->getUsername(), 'cheese') !== false);
}
}

LazyString for Lazy Computation

Since this method will be called every time any User object is loaded from the database, we need to make sure that any logic we run here is fast, especially because the calculated field might not even be needed in all situations! So... if your logic isn't super fast... does that mean you can't use a postLoad listener?

Not necessarily. Another option is to create a LazyString. That's... literally a class inside of Symfony that helps you create strings that won't be evaluated until later - if and when they are needed. Try LazyString::fromCallable() as a neat way to make this custom field lazy:

$bio = LazyString::fromCallable(function() {
    // heavy calculations

    return $complexBio;
});

$user->setBio($bio);

// ---

class User
{
    public function setBio(\Stringable $bio)
    {
        $this->bio = $bio;
    }
}

Registering the Entity Listener

Anyways, entity listeners are not one of the situations where Symfony will magically find our class and start calling the postLoad() method. Nope, we need to tell Doctrine about this with some config. First, inside User, all the way on top, add @ORM\EntityListeners() and pass this an array with our listener class UserSetIsMvpListener::class:

... lines 1 - 17
/**
... lines 19 - 38
* @ORM\EntityListeners({UserSetIsMvpListener::class})
... line 40
*/
class User implements UserInterface
{
... lines 44 - 286
}

I also need to add the use statement manually because PhpStorm doesn't auto-complete it. Add use App\Doctrine\UserSetIsMvpListener;:

... lines 1 - 7
use App\Doctrine\UserSetIsMvpListener;
... lines 9 - 41
class User implements UserInterface
{
... lines 44 - 286
}

As soon as I do that, I can hold Cmd or Ctrl and PhpStorm does recognize the class.

Finally, we need a tiny bit of service configuration to get this working, which is kind of annoying but necessary. Open config/services.yaml... copy the entity listener from the last tutorial, paste and change the name to UserSetIsMvpListener:

... lines 1 - 7
services:
... lines 9 - 39
App\Doctrine\UserSetIsMvpListener:
tags: [doctrine.orm.entity_listener]
... lines 42 - 55

Phew! With any luck, that should be enough for our function to be called every time a user is loaded from the database. Try the test:

symfony php bin/phpunit --filter=testGetUser

We got it! So that is yet another way to populate a custom field when your API resource is an entity class.

But what if you need an API resource that is completely custom: like its data comes from a bunch of different database tables or... it comes from somewhere else entirely? Yep, it's time to create a 100% custom, non-entity API resource.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.6
        "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.7.3
        "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.21.1
        "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.1.2
    }
}