Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

Normalizer Decoration & "Normalizer Aware"

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

Our mission is clear: set up our normalizer to decorate Symfony's core normalizer service so that we can add the owner:read group when necessary and then call the decorated normalizer.

Setting up for Decoration

And we know decoration! Add public function __construct() with private NormalizerInterface $normalizer.

Below in normalize(), add a dump() then return $this->normalizer->normalize() passing $object $format, and $context. For supportsNormalization(), do the same thing: call supportsNormalization() on the decorated class and pass the args.

To complete decoration, head to the top of the class. I'll remove a few old use statements... then say #[AsDecorator] passing serializer, which I mentioned is the service id for the top-level, main normalizer.

Ok! We haven't made any changes yet... so we should still see the one failing test. Try it:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

Woh! An explosion! Wow.

ValidationExceptionListener::__construct() Argument #1 ($serializer) must be of type SerializerInterface, AddOwnerGroupsNormalizer given.

Okay? When we add #[AsDecorator('serializer')], it means that our service replaces the service known as serializer. So, everyone that's depending on the serializer service will now be passed us... and then the original serializer is passed to our constructor.

So, what's the problem? Decoration has worked several times before. The problem is that the serializer service in Symfony is... kind of big. It implements NormalizerInterface, but also DenormalizerInterface, EncoderInterface, DecoderInterface and SerializerInterface! But our object only implements one of these . And so, when our class is passed to something that expects an object with one of those other 4 interfaces, it explodes.

If we truly wanted to decorate the serializer service, we would need to implement all five of those interfaces... which is just a ugly and too much. And that's fine!

Decorating a Lower-Level Normalizer

Instead of decorating the top level normalizer, let's decorate one specific normalizer: the one that's responsible for normalizing ApiResource objects into JSON-LD. This is another spot where you can rely on the documentation to give you the exact service ID you need. It's api_platform.jsonld.normalizer.item.

Try the test again: testOwnerCanSeeIsPublishedField

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

Yes! We see our dump! And... a 400 error? Let me pop open the response so we can see it. Strange:

The injected serializer must be an instance of NormalizerInterface.

And it's coming from deep inside of API Platform's serializer code. So... decorating normalizers is not a very friendly process. It's well-documented, but weird. When you decorate this specific normalizer, you also need to implement SerializerAwareInterface. And that's going to require you to have a setSerializer() method. Oh, let me import that use statement: I don't know why that didn't come automatically. There we go.

Inside, say, if $this->normalizer is an instanceof SerializerAwareInterface, then call $this->normalizer->setSerializer($serializer).

I don't even want to get into the details of this: it just happens that the normalizer we're decorating implements another interface... so we need to also implement it.

Let's try this again.

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

Finally, we have the dump and it's failing the assertion we expect... since we haven't added the group yet. Let's do that!

Adding the Dynamic Group

Remember the goal: if we own this DragonTreasure, we want to add the owner:read group. On the constructor, autowire the Security service as a property... then down here, if $object is an instanceof DragonTreasure - because this method will be called for all of our API resource classes - and $this->security->getUser() equals $object->getOwner(), then call $context['groups'][] to add owner:read.

Phew! Try that test one more time:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

We got it! We can now return different fields on an object-by-object basis.

Also Decorating the Denormalizer

If you want to also add owner:write during denormalization, you would need to implement a second interface. I'm not going to do the whole thing... but you would implement DenormalizerInterface, add the two methods needed, call the decorated service... and change the argument to be a union type of NormalizerInterface and DenormalizerInterface.

Finally, the service that you're decorating for denormalization is different: it's api_platform.serializer.normalizer.item. However, if you want to decorate both the normalizer and denormalizer in the same class, you'd need to remove #[AsDecorator] and move the decoration config to services.yaml... because a single service can't decorate two things at once. API Platform covers that in their docs.

Ok, I'm going to undo all of that... and just stick with adding owner:read. Next: now that we have a custom normalizer, we can easily do wacky things like adding a totally custom field to our API that doesn't exist in our class.

Leave a comment!

Login or Register to join the conversation
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0