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 SubscribeOur 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.
And we know decoration! Add public function __construct()
with private NormalizerInterface $normalizer
:
... lines 1 - 4 | |
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
public function __construct(private NormalizerInterface $normalizer) | |
{ | |
} | |
... lines 12 - 23 | |
} |
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:
... lines 1 - 6 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
... lines 9 - 12 | |
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null | |
{ | |
dump('IT WORKS!'); | |
return $this->normalizer->normalize($object, $format, $context); | |
} | |
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool | |
{ | |
return $this->normalizer->supportsNormalization($data, $format); | |
} | |
} |
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:
... lines 1 - 4 | |
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
... lines 6 - 7 | |
'serializer') ( | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
... lines 11 - 25 | |
} |
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 typeSerializerInterface
,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!
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
:
... lines 1 - 4 | |
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
... lines 6 - 7 | |
'api_platform.jsonld.normalizer.item') ( | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
... lines 11 - 25 | |
} |
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:
... lines 1 - 6 | |
use Symfony\Component\Serializer\SerializerAwareInterface; | |
... lines 8 - 10 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
{ | |
... lines 13 - 28 | |
public function setSerializer(SerializerInterface $serializer) | |
{ | |
... lines 31 - 33 | |
} | |
} |
There we go.
Inside, say, if $this->normalizer
is an instanceof SerializerAwareInterface
, then call $this->normalizer->setSerializer($serializer)
:
... lines 1 - 10 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
{ | |
... lines 13 - 28 | |
public function setSerializer(SerializerInterface $serializer) | |
{ | |
if ($this->normalizer instanceof SerializerAwareInterface) { | |
$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!
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:
... lines 1 - 5 | |
use Symfony\Bundle\SecurityBundle\Security; | |
... lines 7 - 12 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
{ | |
public function __construct(private NormalizerInterface $normalizer, private Security $security) | |
{ | |
} | |
... lines 18 - 38 | |
} |
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
:
... lines 1 - 4 | |
use App\Entity\DragonTreasure; | |
... lines 6 - 12 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
{ | |
... lines 15 - 18 | |
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null | |
{ | |
if ($object instanceof DragonTreasure && $this->security->getUser() === $object->getOwner()) { | |
$context['groups'][] = 'owner:read'; | |
} | |
... lines 24 - 25 | |
} | |
... lines 27 - 38 | |
} |
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.
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.
// 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
}
}
Hello,
I'm trying to make a denormalizer but I don't fit in it while I fit well in
if ($this->denormalizer instanceof DenormalizerInterface) {
Thanks for your help