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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen 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/users.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.
Hi,
how might one filter/create a custom API platform filter for a virtual property like 'isMe'? If someone wanted an API client to be able to filter and retrieve either all resources where isMe is set to false, or all resource where isMe is set to true, how would one best go about that, considering pagination should still be maintained. In the later videos you talk about a custom data provider and a custom paginator, would both these need to be implemented?
Many thanks