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!

This tutorial works great for Symfony 5 and API Platform 2.5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}