Setting a Custom Field Via a 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

When you need to add a custom field... and you need a service to populate the data on that field, you have 3 possible solutions: you can create a totally custom API resource class that's not an entity, you can create an output DTO or you can do what we did: add a non-persisted field to your entity. I like this last option because it's the least... nuclear. If most of your fields come from normal persisted properties on your entity, creating a custom resource is overkill and output DTO's - which are really cool - come with a few drawbacks.

So that's what we did: we created a non-persisted, normal property on our entity, exposed in our API:

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 95
/**
* Returns true if this is the currently-authenticated user
*
* @Groups({"user:read"})
*/
private $isMe;
... lines 102 - 258
public function getIsMe(): bool
{
if ($this->isMe === null) {
throw new \LogicException('The isMe field has not been initialized');
}
return $this->isMe;
}
public function setIsMe(bool $isMe)
{
$this->isMe = $isMe;
}
}

And populated it in a data provider:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 25
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
... lines 28 - 31
foreach ($users as $user) {
$user->setIsMe($currentUser === $user);
}
... lines 35 - 36
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
... lines 41 - 47
$item->setIsMe($this->security->getUser() === $item);
... lines 49 - 50
}
... lines 52 - 56
}

But in reality, there are multiple ways that we could set that field. The data provider solution is the pure API Platform solution. The downside is that if you use your User object in some code that runs outside of an API Platform API call, the $isMe field won't be set!

That might be ok... or you might not even have that situation. But let's look at another idea. What if we create a normal, boring Symfony event listener that's executed early during the request and we set the $isMe field from there.

Let's try it! First, remove our current solution: in UserDataPersister I'll comment-out the $data->setIsMe() and add a comment that this will now be set in a listener:

... lines 1 - 11
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 14 - 34
public function persist($data, array $context = [])
{
... lines 37 - 54
// now handled in a listener
//$data->setIsMe($this->security->getUser() === $data);
... lines 57 - 58
}
... lines 60 - 64
}

Then over in UserDataProvider, I'll do the same thing with the first setIsMe()... and the second:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 25
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
... lines 28 - 31
foreach ($users as $user) {
// now handled in a listener
//$user->setIsMe($currentUser === $user);
}
... lines 36 - 37
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
... lines 42 - 48
// now handled in a listener
//$item->setIsMe($this->security->getUser() === $item);
... lines 51 - 52
}
... lines 54 - 58
}

Sweet! We are back to the broken state where the $isMe field is never set.

Creating the Event Subscriber

To create the event listener, find your terminal and run:

php bin/console make:subscriber

Well, this will really create an event subscriber, which I like a bit better. Let's call it SetIsMeOnCurrentUserSubscriber. And for the event, we want kernel.request. Well, that's its old name. Its new name is this RequestEvent class. Copy that and paste below.

Perfect! Let's go check out the new class in src/EventSubscriber/. And... brilliant! The onRequestEvent() will now be called when the RequestEvent is dispatched, which is early in Symfony:

... lines 1 - 2
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class SetIsMeOnCurrentUserSubscriber implements EventSubscriberInterface
{
public function onRequestEvent(RequestEvent $event)
{
// ...
}
public static function getSubscribedEvents()
{
return [
RequestEvent::class => 'onRequestEvent',
];
}
}

Populating the Field

So our job is fairly simple! We need to find the authenticated User if there is one, and if there is, call setIsMe(true) on it.

Add public function __construct() with a Security $security argument. I'll hit Alt+Enter and go to "Initialize properties" to create that property and set it:

... lines 1 - 7
use Symfony\Component\Security\Core\Security;
class SetIsMeOnCurrentUserSubscriber implements EventSubscriberInterface
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 18 - 39
}

Then down in onRequestEvent(), start with: if not $event->isMasterRequest(), then return:

... lines 1 - 9
class SetIsMeOnCurrentUserSubscriber implements EventSubscriberInterface
{
... lines 12 - 18
public function onRequestEvent(RequestEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}
... lines 24 - 31
}
... lines 33 - 39
}

That's not super important, but if your app uses sub-requests, there's no reason for this code to also run for those. If you don't know what I'm talking about and want to, check out our Symfony Deep Dive Tutorial.

Anyways, get the user with $user = $this->security->getUser() and add some PHPDoc above this to help my editor: we know this will be a User object or null if the user isn't logged in:

... lines 1 - 4
use App\Entity\User;
... lines 6 - 9
class SetIsMeOnCurrentUserSubscriber implements EventSubscriberInterface
{
... lines 12 - 18
public function onRequestEvent(RequestEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}
/** @var User|null $user */
$user = $this->security->getUser();
... lines 27 - 31
}
... lines 33 - 39
}

If there is no user, just return. But if there is a user, call $user->setIsMe(true):

... lines 1 - 9
class SetIsMeOnCurrentUserSubscriber implements EventSubscriberInterface
{
... lines 12 - 18
public function onRequestEvent(RequestEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}
/** @var User|null $user */
$user = $this->security->getUser();
if (!$user) {
return;
}
$user->setIsMe(true);
}
... lines 33 - 39
}

Cool! We just set the $isMe field on the authenticated User object. One cool thing about Doctrine is that if API platform later queries for that same user, Doctrine will return this exact object in memory, which means that the $isMe field will be set to true.

We're now setting the $isMe field for the current user, but purposely not setting it for all other User objects. In the User class, let's now default $isMe to false to mean:

Hey! If we did not set this, it must mean that this is not the currently-authenticated user.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 95
/**
* Returns true if this is the currently-authenticated user
*
* @Groups({"user:read"})
*/
private $isMe = false;
... lines 102 - 267
}

Down in getIsMe(), the LogicException is no longer needed:

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 258
public function getIsMe(): bool
{
if ($this->isMe === null) {
throw new \LogicException('The isMe field has not been initialized');
}
... lines 264 - 265
}
... lines 267 - 271
}

Testing time! At your browser, refresh the item endpoint and... got it! And if we go to /api/cheeses.jsonld... the first item has isMe: true and the others are isMe: false. Love it!

But... what if the object that we need to set the data on is not the User object? How could we get access to the "current API resource object" from inside of our listener? And what if it's a collection endpoint? How could we get access to all the objects that are about to be serialized? Let's chat about the core API Platform event listeners next.

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
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.10
        "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
    }
}