Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

A "Normalizer Aware" Normalizer

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 a User object is being normalized, our UserNormalizer is called. After adding a dynamic group to $context, we want to send the User object back through the full normalizer system: we want the original, "core" normalizer - whatever class that might be - to do its magic. Right now, we're not quite accomplishing that because we're directly calling a specific normalizer - ObjectNormalizer.

So... how can we call the "normalizer chain" instead of this specific normalizer? By implementing a new interface: NormalizerAwareInterface.

... lines 1 - 6
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
... lines 8 - 10
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
... lines 12 - 46

This requires us to have a single new method setNormalizer(). When the serializer system see that a normalizer has this interface, before it uses that normalizer, it will call setNormalizer() and pass a normalizer object... that really holds the "chain" of all normalizers. It, sort of, passes us the top-level normalizer - the one that is responsible for looping over all of the normalizers to find the correct one to use for this data.

So, we won't autowire ObjectNormalizer anymore: remove the constructor and that property. Instead, use NormalizerAwareTrait. That trait is just a shortcut: it has a setNormalizer() method and it stores the normalizer on a protected property.

... lines 1 - 7
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
... lines 9 - 10
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
... lines 14 - 44
}

The end result is that $this->normalizer is now the main, normalizer chain. We add the group, then pass User back through the original process.

Tip

Please, make sure you have Xdebug PHP extension installed and enabled, otherwise the page may just hang if you try to execute this code and you will need to restart your built-in web server or PHP-FPM.

And... some of you may already see a problem with this. When we hit Execute to try this... it runs for a few seconds, then... error!

Maximum function nesting level of 256 reached, aborting!

Yay! Recursion! We're changing the $context and then calling normalize() on the original normalizer chain. Well... guess what that does? It once again calls supportsNormalization() on this object, we return true, and it calls normalize() on us again. We're basically calling ourselves over and over and over again.

Wah, wah. Hmm. We only want to be called once per object being normalized. What we need is some sort of a flag... something that says that we've already been called so we can avoid calling ourselves again.

Avoiding Recursion with a $context Flag

The way to do this is by adding a flag to the $context itself. Then, down in supportsNormalization(), if we see that flag, it means that we've already been called and we can return false.

But wait... the $context isn't passed to supportsNormalization()... so that's a problem. Well... not a big problem - we can get that by tweaking our interface. Remove NormalizerInterface and replace it with ContextAwareNormalizerInterface. We can do that because it extends NormalizerInterface. The only difference is that this interface requires our method to have one extra argument: array $context.

... lines 1 - 6
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
... lines 8 - 10
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 13 - 34
public function supportsNormalization($data, $format = null, array $context = [])
{
... lines 37 - 42
}
... lines 44 - 53
}

For the flag, let's add a constant: private const ALREADY_CALLED set to, how about, USER_NORMALIZER_ALREADY_CALLED. Now, in normalize(), right before calling the original chain, set this: $context[self::ALREADY_CALLED] = true. Finally, in supportsNormalization(), if isset($context[self::ALREADY_CALLED]), return false. That will allow the "normal" normalizer to be used on the second call.

... lines 1 - 10
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 13 - 14
private const ALREADY_CALLED = 'USER_NORMALIZER_ALREADY_CALLED';
... lines 16 - 19
public function normalize($object, $format = null, array $context = array()): array
{
... lines 22 - 25
$context[self::ALREADY_CALLED] = true;
... lines 27 - 32
}
... line 34
public function supportsNormalization($data, $format = null, array $context = [])
{
// avoid recursion: only call once per object
if (isset($context[self::ALREADY_CALLED])) {
return false;
}
return $data instanceof User;
}
... lines 44 - 53
}

We've done it! We've fixed the recursion! Let's celebrate by hitting Execute and... more recursion!

Removing hasCacheableSupportsMethod()

Because... we're missing one subtle detail. Find the hasCacheableSupportsMethod() method - this was generated for us - and return false:

... lines 1 - 49
public function hasCacheableSupportsMethod(): bool
{
return false;
}
... lines 54 - 55

Go back up, hit Execute and... it works! The phoneNumber field is still randomly included... because we have some random logic in our normalizer... but the @id and @type JSON-LD stuff is back!

The hasCacheableSupportsMethod() is an optional method that each normalizer can have... which relates to this optional interface - CacheableSupportsMethodInterface. The purpose of this interface - and the method - is performance.

Because every piece of the object graph is normalized, the serializer calls supportsNormalization()... a lot of times. If your supportsNormalization() method only relies on the $format and the class of $data - basically $data instanceof User, then you can return true from hasCacheableSupportsMethod(). When you do this, the serializer will only call supportsNormalization() once per class. That speeds things up.

But as soon as you rely on the $data itself or the $context, you need to return false... or remove this interface entirely, both have the same result. This forces the serializer to call our supportNormalization() method each time the chain is called. That does have a performance impact, but as long as your logic is fast, it should be minor. And most importantly, it fixes our issue!

Next, let's add the proper security logic to our class and then investigate another superpower of normalizers: the ability to add completely custom fields. We'll add a strange... but potentially useful boolean field to User called isMe.

Leave a comment!

23
Login or Register to join the conversation
Default user avatar
Default user avatar Georgi Kolev | posted 1 year ago | edited

Hey there
I found an issue with this approach.
When you have nested objects the custom normalizer is not called on the embedded object, because the context seems preserved and the flag is up (set in the parent object) and the normalizer is skipped. :(

2 Reply

Hey @Georgi!

Ah! Is it an object with a self referencing relationship (and so the embedded objects are of the same class)? Indeed, this is very possible. I think what you would need to do is, instead of storing a Boolean to track if our normalizer is called, store a list of spl_object_id.

So basically, set a new key on the context - like ALREADY_NORMALIZED_OBJECT_IDS that is an array. Then, use spl_object_id() on the object that’s being normalized. Use that to see if that object has been normalized. And set the id onto that context array.

I may be overlooking some detail - but let me know if that works :). In general, I do wish custom normalizers were a bit easier to create ;).

Cheers!

Reply
Anton B. Avatar
Anton B. Avatar Anton B. | posted 1 year ago | edited

there when I tried to create a custom Normalizer the embedded objects are not being denormalized. So I have the following:

"objects": {
"1": {
@id: "hello",
@type: "World"
}
}

How can I avoid this?

Reply

Hey Anton B.!

Hmmm. Can you post your denormalizer and the API class that's in question? This stuff is complex :p.

Cheers!

Reply
Simon L. Avatar
Simon L. Avatar Simon L. | posted 1 year ago

Hi there!

I am trying to build the deserialization version. My goal if to give permission for a user (with no ROLE_ADMIN) to update some of his own properties.

So in the entity User, there is for example:


/**
* Can be modified by admins and the user himself
* @ORM\Column(type="string", length=255, nullable=true)
* @Groups({"userhimself:read", "userhimself:write", "admin:read", "admin:write"})
*/
private $firstName;

Here is my deserializer:


security = $security;
}

/**
* @param UserObject $data
*/
public function denormalize($data, string $type, ?string $format = null, array $context = [])
{
$context['groups'] = $context['groups'] ?? [];

if (CONDITION DATA === USER, BUT DATA IS AN ARRAY...) {
$context['groups'][] = 'userhimself:write';
}

$context[self::DE_ALREADY_CALLED] = true;

$data = $this->denormalizer->denormalize($data, $type, $format, $context);

// Here: add, edit, or delete some data

return $data;
}

public function supportsDenormalization($data, string $type, ?string $format = null, array $context = [])
{
if (isset($context[self::DE_ALREADY_CALLED])) {
return false;
}
return $type === 'App\Entity\UserObject';
}

public function hasCacheableSupportsMethod(): bool
{
return false;
}
}

So I wrote the problem inside the code: I have to check if the user requesting the update is the user described in $data, but $data is just an array which can be like that:


['firstName' => 'Alice']

So I can't just do for example:


if ($data === $this->security->getUser()) {...

How can I do to achive my goal please?

Reply
Simon L. Avatar

I found where the information about the object is:
$context[AbstractItemNormalizer::OBJECT_TO_POPULATE]

So I did


if ($context[AbstractItemNormalizer::OBJECT_TO_POPULATE] === $this->security->getUser()) {

And it worked

Reply

Hey Simon L.

Nice to see that you solved your issue! Probably better will be to compare users ID instead of complete user objects

Cheers!

1 Reply
Simon L. Avatar
Simon L. Avatar Simon L. | sadikoff | posted 1 year ago | edited

Hi sadikoff !

Yes I noticed that @weaverryan is usually comparing Ids instead of the full objects themselves. Is there any reason for this, like performance? Or is it a matter of taste?

I feel like comparing Ids make the software computing for 2 more methods (->getId() x2) , so my intuition tells me it is less optimized.

Waiting to hear from you :)

Reply

Your thought makes sense, but what if somehow you get 2 instances of the same object? Then === will not work. From performance view getId() doesn't compute anything, it just returns pre-populated data so the win of not using it will be soooo minimal :) Probably even no win at all!

So as a result:
* Comparing objects can lead to the mistake and probably no performance win.
* Comparing ID +1 method call, more stable code and let say no performance impact

Cheers!

Reply
Titoine Avatar
Titoine Avatar Titoine | posted 2 years ago

"Let me put my groups and then do your job again but this time, ignore me."
I find the decorator pattern for the ContextBuilder far more intuitive. Am I comparing apples to oranges?

Reply

Hey @keuwa!

You're 100% correct. I really dislike how hard it is to decorate normalizers - I think it's a design flaw. So... I agree ;). It did not *need* to be built this way - it would be better if you could hook into the normalization process to change groups, but without completely "taking over" the process and requiring us to call the system again.

Cheers!

Reply
hanen Avatar
hanen Avatar hanen | posted 2 years ago | edited

there
Error
*in \src/Serializer/Normalizer/UserNormalizer.php (line 37)
private function userIsOwner(User $user): bool
*UserNormalizer->userIsOwner(object(User))
UserNormalizer->normalize(object(User), 'jsonld', array('groups' => array('owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read', 'owner:read',......

Reply

hm this look weird! Why there is so many groups?

Reply
hanen Avatar
hanen Avatar hanen | sadikoff | posted 2 years ago | edited

sadikoff the code looks allright there must be an issue somewhere else :(((

Reply
hanen Avatar
hanen Avatar hanen | hanen | posted 2 years ago | edited

hanen I resolved when I clearing the cache for the dev environment :))))

Reply
AymDev Avatar

My computer did not stop at 256 recursion levels, I just restarted it. Thanks, team ! 😂

Reply

Hey AymDev,

Wooops, we are sorry about that! :) Yeah, without Xdebug installed it might hang :p Btw, in theory you can just restart your php-fpm and it should do the trick. We probably need to add a warning if you don't have Xdebug installed :)

Cheers!

Reply
AymDev Avatar

Oh, right ! I don't have Xdebug installed...
Well, maybe restarting php-fpm does the trick but the computer completely froze, I had to do an evil manual reboot. Thank you for the advices !

Reply

Hey AymDev,

Oh, sorry for this again! Seems it's more heavy than I thought :/ Yeah, that exact "Maximum function nesting level of 256 reached, aborting!" message is coming from Xdebug, IIRC the directive for this is "xdebug.max_nesting_level=256", so you can control it in your PHP ini config but only when Xdebug extension is installed :)

Cheers!

Reply
hanen Avatar

Hello Victor , I got the same error .. I set the xdebug.max_nesting_level up to 500 in php .ini and restart all services when I m running the command composer update Im getting error in my cheeselistingRepository :
Cannot autowire service "App\Repository\CheeseListingRepository": argument "$registry" of method "__construct()" references interface "Symfony\
Bridge\Doctrine\RegistryInterface" but no such service exists. Try changing the type-hint to "Doctrine\Persistence\ManagerRegistry" instead.

I get Xdebug working with PhpStorm..
in php .ini the Xdebug section:
[Xdebug]
zend_extension="c:\xampp\php\ext\php_xdebug-2.9.1-7.3-vc15.dll"
xdebug.remote_enable=1
xdebug.remote_port=9000
xdebug.max_nesting_level=500

Reply

Hey @henene

Sounds like some bundle or symfony version was updated, have you tried to change type-hint, as described in error?

Cheers

1 Reply
hanen Avatar

Hi , I solve the cheeselisting & user Repository error but what about this error message "Maximum function nesting level of '500' reached, aborting!" after I increased the maximum nesting level of function !!

Reply

Have you passed all chapter and put every line of code at needed place? Looks like you have some object that doesn't stops recursive normalization. Can you show exact error, and your normalizer class?

Cheers!

Reply
Cat in space

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

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
    }
}