Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Embedded Relations

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

So when two resources are related in our API, they show up as an IRI string, or collection of strings. But you might wonder:

Hey, could we include the DragonTreasure data right here instead of the IRI so that I don't need to make a second, third or fourth request to get that data?

Absolutely! And, again, you can also do something really cool with Vulcain... but let's learn how to embed data.

Embedding Vs IRI via Normalization Groups

When the User object is being serialized, it uses the normalization groups to determine which fields to include. In this case, we have one group called user:read. That's why email, username and dragonTreasures are all returned.

... lines 1 - 16
normalizationContext: ['groups' => ['user:read']],
... line 19
... lines 21 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 25 - 30
#[Groups(['user:read', 'user:write'])]
... lines 32 - 33
private ?string $email = null;
... lines 35 - 46
#[Groups(['user:read', 'user:write'])]
... line 48
private ?string $username = null;
... lines 50 - 51
private Collection $dragonTreasures;
... lines 54 - 170

To transform the dragonTreasures property into embedded data, we need to go into DragonTreasure and add this same user:read group to at least one field. Watch: above name, add user:read. Then... go down and also add this for value.

... lines 1 - 51
class DragonTreasure
... lines 54 - 59
#[Groups(['treasure:read', 'treasure:write', 'user:read'])]
... lines 61 - 63
private ?string $name = null;
... lines 65 - 75
#[Groups(['treasure:read', 'treasure:write', 'user:read'])]
... lines 77 - 78
private ?int $value = 0;
... lines 80 - 209

Yup, as soon as we have even one property inside of DragonTreasure that's in the user:read normalization group, the way the dragonTreasures field looks will totally change.

Watch: when we execute that... awesome! Instead of an array of IRI strings, it's an array of objects, with name and value... and of course the normal @id and @type fields.

So: when you have a relation field, it will either be represented as an IRI string or an object... and this depends entirely on your normalization groups.

Embedding the Other Direction

Let's try this same thing in the other direction. We have a treasure whose id is 2. Head up to the GET a single treasure endpoint... try it... and enter 2 for the id.

No surprise, we see owner as an IRI string. Could we turn that into an embedded object instead? Of course! We know that DragonTreasure uses the treasure:read normalization group. So, go into User and add that to the username property: treasure:read.

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 25 - 46
#[Groups(['user:read', 'user:write', 'treasure:read'])]
... line 48
private ?string $username = null;
... lines 50 - 170

With just that change... when we try it... yes! The owner field just got transformed into an embedded object!

Embedded for One Endpoint, IRI for Another

Ok, let's also fetch a collection of treasures: just request all of them. Thanks to the change we just made, every single treasure's owner property is now an object.

That gives me a wild, hare-brained idea. What if having all the owner information when I fetch a single DragonTreasure is cool... but maybe it feels like overkill to have that data returned from the collection endpoint. Could we embed the owner when fetching a single treasure... but then use the IRI string when fetching a collection?

The answer is... no! I'm kidding - of course! We can do whatever crazy things we want! Though, the more weird things you add to your API, the trickier life gets. So choose your adventures wisely!

Doing this is a two-step process. First in DragonTreasure, find the Get operation, which is the operation for fetching a single treasure. One of the options that you can pass into an operation is the normalizationContext... which will override the default. Add normalizationContext, then groups set to the standard treasure:read. Then add a second group that's specific to this operation: treasure:item:get.

... lines 1 - 25
... lines 27 - 28
operations: [
new Get(
normalizationContext: [
'groups' => ['treasure:read', 'treasure:item:get'],
... lines 35 - 38
... lines 40 - 53
... line 55
class DragonTreasure
... lines 58 - 213

You can call this whatever you want... but I like this convention: resource name followed by item or collection then the HTTP method, like get or post.

And yes, I did forget the groups key: I'll fix that in a minute.

Anyways, if I had coded this correctly, it would mean that when this operation is used, the serializer will include all fields that are in at least one of these two groups.

Now we can leverage that. Copy the new group name. Then, over in User, above username, instead of treasure:read, paste that new group.

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 25 - 46
#[Groups(['user:read', 'user:write', 'treasure:item:get'])]
... line 48
private ?string $username = null;
... lines 50 - 170

Let's check it out! Try the GET collection endpoint again. Yes! We're back to owner being an IRI string. And if we try the GET one endpoint.. oh, the owner is... also an IRI here too? That's my bad. Back on normalization_context I forgot to say groups. I was basically setting two meaningless options into normalization_context.

Let's try that again. This time... got it!

When you get fancy like this, it does get a bit harder to keep track of what serialization groups are being used and when. Though you can use the Profiler to help with that. For example, this is our most recent request for the single treasure.

If we open the profiler for that request... and go down to the Serializer section, we see the data that's being serialized... but more importantly the normalization context... including groups set to the two we expect.

This is also cool because you can see other context options that are set by API Platform. These control certain internal behavior.

Next: let's get crazy with our relationships by using a DragonTreasure endpoint to change the username field of that treasure's owner. Woh.

Leave a comment!

Login or Register to join the conversation
Carlos-33 Avatar
Carlos-33 Avatar Carlos-33 | posted 1 month ago | edited

Hi everyone! I'm currently using the API Platform for my personal project. However, I'm facing an issue. I have three entity relations, which means three levels of embedded relations with normalizationContext groups. The problem is that I can only retrieve data object from the 2 stages. The format of the third stage is a URI and not object.


Hi @Carlos-33,

Interesting, as far as I know there is no limit... is it 3 different entities? Can you re-check if 3rd entity has correct group set on fields you want to read, if it's like a Tree with single entity try #[MaxDepth(2)] attribute to configure serialisation process


Jeremy Avatar
Jeremy Avatar Jeremy | posted 1 month ago | edited

Hi! Thank you for all these awesome courses! I love it, I stopped my netflix subscription as I prefer to chill on SymfonyCast ;)

Aren't serialization groups going whild when you have to add role access to differents fields of the entity, when you have several roles (admin / seller / customer / public...)? How do you deal with this? Isn't it starting to be a mess in Entities? And when your API is growing with 10 or 20 entities with relations that you need to embed? Maybe you don't embed anymore and rely on Vulcain in this case?


Edit : Oh sorry I was in too much of a hurry, all my questions seems to have an answer in the next part : https://symfonycasts.com/screencast/api-platform-security
Thank you very much


Hey Jeremy!

Haha, I hope SymfonyCasts is more interesting for you ;)

So it seems you found the answers in the next chapters - great, I'm happy to hear it :)


Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | posted 7 months ago

Hello there!
Quick question when you have some extra time.
In your exact configuration, when you make a PUT request on the USER, to change for example its username, what do you get in the response, specifically in the embedded collection? Do you get the extra fields you configured or just the IRIs of the treasures owned by the User?
In my very similar case, when I GET the parent, it works just fine, the extra fields of the Children are there. But when I update the parent entity, the configurated fields of the children are systemically replaced by the unique IRIs, breaking all the UI using those fields (for example an Image I display using its name).
I really don't get what I'm doing wrong and don't find where to look for my mistake.
Thanks :)


Hi Jean!

I might know the problem you're referring to! Are you, like we do in this chapter, adding an extra (de)normalization group for one specific operation? Like, in this chapter, we add treasure:item:get to just the Get operation. Are you doing something like that?

If so, the problem is that when you make a GET request, it will (of course) normalize with the extra group - e.g. treasure:item:get. But when you make a PUT request, it will first look for the denormalization groups to "read" the JSON you're sending. Then it will look on the Put operation to see what normalization groups it should use. If you've set things up like we did in this chapter, then the Put request will NOT have the extra treasure:item:get. The solution would be to add that extra group also to the PUT operation. Heck, if you care enough, you might even add it to the Post operation so that the extra fields are returned even after you "create" a resource.

Let me know if that's the problem - I wondered while I was recording this if that would trick some people.


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.0.8
        "doctrine/annotations": "^1.0", // 1.14.2
        "doctrine/doctrine-bundle": "^2.8", // 2.8.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.0
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.64.1
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.15.3
        "symfony/asset": "6.2.*", // v6.2.0
        "symfony/console": "6.2.*", // v6.2.3
        "symfony/dotenv": "6.2.*", // v6.2.0
        "symfony/expression-language": "6.2.*", // v6.2.2
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.3
        "symfony/property-access": "6.2.*", // v6.2.3
        "symfony/property-info": "6.2.*", // v6.2.3
        "symfony/runtime": "6.2.*", // v6.2.0
        "symfony/security-bundle": "6.2.*", // v6.2.3
        "symfony/serializer": "6.2.*", // v6.2.3
        "symfony/twig-bundle": "6.2.*", // v6.2.3
        "symfony/ux-react": "^2.6", // v2.6.1
        "symfony/validator": "6.2.*", // v6.2.3
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.0
        "symfony/yaml": "6.2.*" // v6.2.2
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.2.*", // v6.2.1
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/stopwatch": "6.2.*", // v6.2.0
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.4
        "zenstruck/foundry": "^1.26" // v1.26.0