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 SubscribeCopy the test method - testOwnerCanSeeIsPublishedField
. We just added some magic so that admin users can see the isPublished
property. This method tests for our next mission: that owners of a DragonTreasure
can also see this.
Run it with:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
And... it fails: expected null
to be the same as false
, because the field isn't returned at all.
To fix this, over in DragonTreasure
, add a third special group: owner:read
:
... lines 1 - 88 | |
class DragonTreasure | |
{ | |
... lines 91 - 128 | |
'admin:read', 'admin:write', 'owner:read']) ([ | |
private bool $isPublished = false; | |
... lines 131 - 249 | |
} |
Can you see where we're going with this? If we are the owner of a DragonTreasure
, we'll add this group and then the field will be included. However, pulling this off is tricky.
As we talked about in the last video, normalization groups start static: they live up here in our config. The context builder allows us to make these groups dynamic per request. So, if we're an admin user, we can add an extra admin:read
group, which will be used when serializing every object for this entire request.
But in this situation, we need to make the group dynamic per object. Imagine if we're returning 10 DragonTreasure
's: the user may only own one of them, so only that one DragonTreasure
should be normalized using this extra group.
To handle this level of control, we need a custom normalizer. Normalizers are core to Symfony's serializer. They're responsible for turning a piece of data - like an ApiResource
object or a DateTime
object that lives on a property - into a scalar or array value. By creating a custom normalizer, you can do pretty much any weird thing you want!
Find your terminal and run:
php bin/console debug:container --tag=serializer.normalizer
I love this: it shows us every single normalizer in our app! We can see stuff that's responsible for normalizing UUIDs.... this is what normalizes any of our ApiResource
objects to JSON-LD
and here's one for a DateTime
. There's a ton of interesting stuff.
Our goal is to create our own normalizer, decorate an existing core normalizer, then add the dynamic group before that core normalizer is called.
So let's get to work! Over in src/
- it doesn't really matter how we organize things - I'm going to create a new directory called Normalizer
. Let me collapse a few things... so it's easier to look at. Inside that, add a new class called, how about, AddOwnerGroupsNormalizer
. All normalizers must implement NormalizerInterface
... then go to "Code"->"Generate" or Command
+N
on a Mac and select "Implement methods" to add the two we need:
... lines 1 - 2 | |
namespace App\Normalizer; | |
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
public function normalize(mixed $object, string $format = null, array $context = []) | |
{ | |
// TODO: Implement normalize() method. | |
} | |
public function supportsNormalization(mixed $data, string $format = null) | |
{ | |
// TODO: Implement supportsNormalization() method. | |
} | |
} |
Here's how this works: as soon as we implement NormalizerInterface
, anytime any piece of data is being normalized, it will call our supportsNormalization()
method. There, we can decide whether or not we know how to normalize that thing. If we return true
, the serializer will then call normalize()
, pass us that data, and then we return the normalized version.
And actually, to avoid some deprecation errors, pop open the parent class. The return type is this crazy array thingy. Copy that... and add it as the return type. You don't have to do this - everything would work without it - but you'd get a deprecation warning in your tests.
Down for supportsNormalization()
, in Symfony 7, there will be an array $context
argument... and the method will return a bool
:
... lines 1 - 6 | |
class AddOwnerGroupsNormalizer implements NormalizerInterface | |
{ | |
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null | |
... lines 10 - 12 | |
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool | |
... lines 15 - 17 | |
} |
Before we fill this in or set up decoration, we need to think about which core service we're going to decorate. Here's my idea: if we replace the main core normalizer
service with this class, we could add the group then call the decorated normalizer... so that everything then works like usual, except that it has the extra group.
Back at the terminal, run:
bin/console debug:container normalizer
We get back a bunch of results. That makes sense: there's a main normalizer
, but then the normalizer
itself has lots of other normalizers inside of it to handle different types of data. So... where is the top level normalizer? It's actually not even in this list: it called serializer
. Though, as we'll see next, even that isn't quite right.
Hi,
I'm not fully understand what are you trying to do, but in Symfony(not ApiPlatform) all you need just create this normalizer and that's all, it will be automatically registered in system and it will be used for objects which are defined in support()
method
$serializer = new Serializer([new MyCustomNormalizer()], [new Converter()])
Such things will never work, you are instantiating Classes manually so you need to get all arguments from service container or instantiate them as they are defined!
Cheers!
// 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
}
}
Hi! I have an issue by using a custom normalizer (but not with API Platform). As mentioned in the Symfony documentation, it's possible to create a custom normalizer and inject services in its constructor :
But when I try to instantiate it by calling
$serializer = new Serializer([new MyCustomNormalizer()], [new Converter()])
I have an issue like "to few arguments for MyCustomNormalizer: 0 passed, 2 expected..."What is the correct way to do that?
Thanks for your help
Cyril