Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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/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.

Leave a comment!

6
Login or Register to join the conversation
Johanna B. Avatar
Johanna B. Avatar Johanna B. | posted 1 year ago

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

Reply

Hey Johanna B.!

My apologies for the very slow reply - I was just starting a holiday when you commented, and the team left this (tough) question for me :). I think, unless I'm neglecting a complication - that adding a filter for a virtual property would not be any different than any other custom filter. For example, here is the main video where we implement a custom filter for a Doctrine entity - https://symfonycasts.com/sc...

In that example, in getDescription(), we name the filter "search". But that does not need to correspond to any property name on our class. So we could also call it "isMe" or "fooBar". It really controls what the query parameter will be called. So, assuming you call your filter isMe, in filterProperty, if $property is set to isMe, then you could then do whatever logic you wanted to modify the query. Specifically, you would probably get the current user and add something like:


$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.owner = :owner', $alias))
->setParameter('owner', $this->security->getUser());

If you do this, I think it's all you need. You would still be using the built-in entity data provider, which is nice because that also automatically handles pagination. For example, if I own 50 CheeseListing, then the results for isMe=1 would automatically be paginated for me.

If I'm missing some complication, let me know - I'm just thinking through this problem, so something may not be occurring to me :).

Cheers!

Reply
Johanna B. Avatar

Many thanks for your reply! Yes, that makes a lot of sense to me. If you don't mind me asking another question - what now happens if we have a virtual property which we cannot (feasibly) filter for using the queryBuilder, perhaps because it involves very complicated logic and is far removed from any property mapped to the database? I hope my question makes sense! Many thanks

Reply

Hey Johanna B.!

My pleasure - but sorry for the slow reply to this one! :).

> what now happens if we have a virtual property which we cannot (feasibly) filter for using the queryBuilder, perhaps because it involves very complicated logic and is far removed from any property mapped to the database

One way or another (even if you were building a normal web app and not an API), you will need to create a query to pull items back from the database. So, somehow, you really need to modify the query to fetch the results you need. If, for some reason, the logic was SO complex that you needed to make a query to the database... and then do extra logic in PHP to filter the final results then, hmm, I think you would need a completely custom data provider for this :). But then you would need to manually add pagination support to that.

Cheers!

Reply
Carlos Avatar

There are several entities in our projects that have a "json_data" field (json type in MySQL). This happens because, as our systems are used in several different clients, there are clients of ours that need some specific fields, and instead of creating fields directly in our entities and tables in MySQL (and that way we end up with polluted tables with many fields that don't make sense to other customers of ours), we store as dynamic fields within that json_data field.
I wonder if there is any way to correctly display these peculiarities in the swagger documentation.

The idea of "dynamic fields" within the json field would not be to prevent the API user from sending any invalid values (this validation is done in another way, at runtime), but simply to inform that that installation of our system expects to receive such and such dynamic fields. I don’t intend to create any type of validation to prevent the user from sending fields beyond what is expected, because, over time, new dynamic fields can be created, so that part of the records in the tables would be from a previous "json metadata" and another part would already have the values of a more current "json metadata".

If there was a way to "hack" swagger documentation to inform the user about this details would be nice. And it would be perfect if api-platform created the calls to POST already showing the fields inside json!

Reply

Hey Carlos!

That's a pretty interesting question :). I can, at least, give you some hints!

A) What you ultimately want to do is update the OpenAPI JSON documentation - which you can see directly if you go to /api/docs.json.

B) This JSON is created just like any other JSON in the system: via a normalizer. Specifically, it is this DocumentationNormalizer - https://github.com/api-plat...

C) So, like anything else in the system, you can add a custom normalizer that "extends" this. Though, I have never done this, and this is a very complex class :). Iirc, it eventually calls - https://github.com/api-plat... - so if I'm correct, it may even be easier to "decorate" this... or I think it also uses the "property name collection factory" to get the "array of all properties for a class"... and so you could also create a custom https://github.com/api-plat... and use decoration to put your custom class into the system (your custom property name collection factory would return your dynamic properties).

That was a big brain dump - let me know if any of it helps :).

Cheers!

1 Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.0 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "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.18.7
        "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.8.0
        "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.23.0
        "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.8.0
    }
}