Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Embedded Relation

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 two resources are related to each other, this can be expressed in two different ways in an API. The first is with IRIs - basically a "link" to the other resource. We can't see the data for the related CheeseListing, but if we need it, we could make a second request to this URL and... boom! We've got it.

But, for performance purposes, you might say:

You know what? I don't want to have to make one request to fetch user data and then one more request to get the data for each cheese listing they own: I want to get it all at once!

And that describes the second way of expressing a relationship: instead of just returning a link to a cheese listing, you can embed its data right inside!

Embedded CheeseListing into User

As a reminder, when we normalize a User, we include everything in the user:read group. So that means $email, $username and $cheeseListings, which is why that property shows up at all.

To make this property return data, instead of just an IRI, here's what you need to do: go into the related entity - so CheeseListing - and add this user:read group to at least one property. For example, add user:read above $title... and how about also above $price.

... lines 1 - 37
class CheeseListing
... lines 40 - 46
... line 48
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read"})
... lines 50 - 55
private $title;
... lines 58 - 65
... lines 67 - 69
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read"})
... line 71
private $price;
... lines 74 - 194

Let's see what happens! We don't even need to refresh, just Execute. Woh! Instead of an array of strings, it's now an array of objects! Well, this user only owns one CheeseListing, but you get the idea. Each item has the standard @type and @id plus whatever properties we added to the group: title and price.

It's beautifully simple: the serializer knows to serialize all fields in the user:read group. It first looks at User and finds email, username and cheeseListings. It then keeps going and, inside of CheeseListing, finds that group on title and price.

Relation Strings vs Objects

This means that each relation property may be a string - the IRI - or an object. And an API client can tell the difference. If you get back an object, you know it will have @id, @type and some other data properties. If you get back a string, you know it's an IRI that you can use to go get the real data.

Embedding User into CheeseListing

We can do the same thing on the other side of the relationship. Use the docs to get the CheeseListing with id = 1. Yep! The owner property is a string. But it might be convenient for the CheeseListing JSON to at least contain the username of the owner... so we don't need to go fetch the entire User just to display who owns it.

Inside CheeseListing, the normalization process will serialize everything in the cheese_listing:read group. Copy that. The owner property, of course, already has this group above it, which is why we see it in our API. Inside User, find $username... and add cheese_listing:read to that.

... lines 1 - 22
class User implements UserInterface
... lines 25 - 51
... line 53
* @Groups({"user:read", "user:write", "cheese_listing:read"})
... line 55
private $username;
... lines 58 - 184

Let's try this thing! Move back over and... Execute! And... ha! Perfect! It expands to an object and includes the username.

Embedding Data Only on when GETing a Single Item

Does it work if we GET the collection of cheese listings? Try it out! Well... ok, there's only one CheeseListing in the database right now, but of course! It embeds the owner in the same way.

So... about that... new challenge! What if we want to embed the owner data when I fetch a single CheeseListing... but, to keep the response from being gigantic... we don't want to embed the data when we fetch the collection. Is that possible?

Totally! Again, for CheeseListing, when we normalize, we include everything in the cheese_listing:read group. That's true regardless of whether we're GETting the collection of cheese listings or just GETting a single item. But, tons of things - including groups - can be changed on an operation-by-operation basis.

For example, under itemOperations, break the get operation configuration onto multiple lines and add normalization_context. One of the tricky things with the config here is that the top-level keys are lower camel case, like normalizationContext. But deeper keys are usually snake case, like normalization_context. That... can be a little inconsistent - and it's easy to mess these up. Be careful.

Anyways, the goal is to override the normalization context, but only for this one operation. Set this to the normal groups and another array. Inside, we're going to say:

Hey! When you are getting a single item, I want to include all of the properties that have the cheese_listing:read group like normal. But I also want to include any properties in a new cheese_listing:item:get group.

... lines 1 - 16
* @ApiResource(
... line 19
* itemOperations={
* "get"={
* "normalization_context"={"groups"={"cheese_listing:read", "cheese_listing:item:get"}},
* },
... line 24
* },
... lines 26 - 32
* )
... lines 34 - 38
class CheeseListing
... lines 41 - 198

We'll talk more about it later - but I'm using a specific naming convention for this operation-specific group - the "entity name", colon, item or collection, colon, then the HTTP method - get, post, put, etc.

If we re-fetch a single CheeseListing.... it makes no difference: we're including a new group for serialization - yaaaay - but nothing is in the new group.

Here's the magic. Copy the new group name, open User, and above the $username property, replace cheese_listing:read with cheese_listing:item:get.

... lines 1 - 22
class User implements UserInterface
... lines 25 - 51
... line 53
* @Groups({"user:read", "user:write", "cheese_listing:item:get"})
... line 55
private $username;
... lines 58 - 184

That's it! Move back to the documentation and fetch a single CheeseListing. And... perfect - it still embeds the owner - there's the username. But now, close that up and go to the GET collection endpoint. Execute! Yes! Owner is back to an IRI!

These serialization groups can get a little complex to think about, but wow are they powerful.

Next... when we fetch a CheeseListing, some of the owner's data is embedded into the response. So... I have kind of a crazy question: when we're updating a CheeseListing... could we also update some data on the owner by sending embedded data? Um... yea! That's next.

Leave a comment!

Login or Register to join the conversation
Musa Avatar

Hey! Going out on a limb here.
In the sorely missed polymorphism I've previously used in laravel projects, I've created a unidirectional relationship with a "price" entity that many products have a manyToOne relationship to (@ORM\ManyToOne(targetEntity=Price::class)).

This works for all intents and purposes, but the embed does not seem to work, with the right groups in the right places, the relation still returns an IRI.

Since I'm not using traditional relationships where everything is bidirectional (although doctrine seems to think uni is fine), any chance someone knows/can confirm, that unidirectional relationships do not possess this functionality?

Musa Avatar

So it does work, I was just too tunnel vision'ed to see why, I had two @ApiResource annotations.
Remember to check your annotations people, IDE won't help you here hahaha.

MolloKhan Avatar MolloKhan | SFCASTS | Musa | posted 1 year ago | edited

Hey Musa

Yeah, that's the problem with annotations, they're hard to validate. If you're on PHP8 you may want to consider migrating to PHP attributes


Jovan P. Avatar
Jovan P. Avatar Jovan P. | posted 3 years ago | edited

I have an interesting scenario:

  • trait HasTimestamp
  • Class A (uses HasTimestamp)
  • Class B (uses HasTimestamp)
  • Object of class A has a reference to object of class B

I would like to return some object of class A, but include only IRI of B.
The way I see it would have to mark property in HasTimestamp with @Group

  • @Group({"Common"}) and add "Common" to normalizationContext of A's GET
  • @Group({"A:read", "B:read"}) - and puts those to their classes' normalizationContext

But either way, I get:


"@type": "A",
"@id": "/api/a/1",
"mtime": "some datetime", <-- This comes from the HasTimetamp
... other simple properties
"b": {
    "@type": "B",
    "@id": "/api/b/1",
    "mtime": "mtime": "some datetime", <-- This also comes from the HasTimetamp



Is there any way to tell the Serializer not to go down the object B, for this property, despite the @Groups?

Thanks! :)

Jovan P. Avatar

I think I've got the it, but I would really appreciate any thoughts on this. I wouldn't want to go down any hackish solution :)

Just added `@ApiProperty(readableLink=false)` to property `b` of class A. Does that make sense?

But this comes at a cost: What if I wanted to get only B's title (and not just IRI)? This brings me back to square one :(


Hey Jovan P.!

Hmm, interesting problem. You're correct that this should be handled via @Group... but I guess the tricky part is that the properties are shared in the trait, and so you can't give the HasTimestamp properties different groups in each entity. If you were using Yaml or XML config for API Platform this wouldn't be a problem, but I don't think that you can mix them (have most config in annotations, and just one property in Yaml/XML).

About readableLink=false, from my understanding of that option - https://github.com/api-platform/core/issues/479 - I think your solution is correct/valid. But I see your point that this completely turns things into an IRI... but if you wanted to include an embedded object but avoid the "mtime" field, then that's still not possible. You effectively want to be able to change the "group" of a property in an object at runtime, and I'm not sure that's possible (well, probably possible, but maybe not easy/nice).

The only solution I can think of is to:

A) put no groups into HasTimestamp so that they are not serialized
B) Inside A, add getModifiedTime() that returns mtime and put the A:read group above this. Repeat the same for B and put B:read above that.

The downside is more work :). The upside is that you are explicitly controlling what fields you want serialized.

Let me know what you think!


1 Reply
Jovan P. Avatar

I actually very much like the idea of having explicit control with methods. Nice! :) The whole time I was chasing the solution by annotating the properties, but that sounds much more elegant even if that means just a bit more of code.

Thanks Ryan! :)

Daniel K. Avatar
Daniel K. Avatar Daniel K. | posted 3 years ago

How to increase embedded form depth? because i think default is 2
i added "userQueue:next" in @Groups in all related entities but only first relation is embedded document, rest are IRI


Hey Daniel K.

You can define the depth by using the MaxDepth annotation. You can check it on the docs here (almost at the bottom of the topic): https://api-platform.com/docs/core/serialization/#using-serialization-groups


Miky Avatar

Hi, it's possible to use MaxDepth with sorting value ?
Example.. i have user and want also embedded data as last 5 transactions or transaction for 30 days.
Is this possible by annotation or i need to serape api requests to solve this scenario?


Hey @A. Mikolaj!

Hmm. I’m not sure about MaxDepth and how that fits into things. But here is how I’d solve this.

I would create, for example, a getRecentTransactions() method. Inside, filter the collection how you want (last 5 or last 30 days). You can use Doctrine’s criteria system to do this efficiently (without querying for ALL the transactions... only to limit them later). Details at https://symfonycasts.com/sc...

Once you’ve done this, you can expose this as a field via @Groups().

Does this help? I feel like I might be missing a detail - because I can’t think of how MaxDepth fits in here. So let me know :).


1 Reply

Hi all,
I have a many to many relation to itself (via a join table).
And i think this is the challenge.

Relation is working just fine as long as i only show the links to the relation.
As soon as i want to show the related data, i get this error.
<blockquote> "hydra:title": "An error occurred",
"hydra:description": "The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary, or use the \"max_depth\" option of the Symfony serializer.",</blockquote>

Do i need to use 'max depth' and if so where do i put it, in the 'parent' or the 'child'? I've tried, but i keep getting the message.
Thanks in advance.

In the mean time I have made an entity for this join table, because i need some extra fields in this join table.
So now i have a one-to-many and a many-to-one relation to this join table.
This doesn't solve the problem ofcourse.

I think i solved it<spoiler>`

  • @ApiResource(
  • attributes={
  • "force_eager"=false,

Hey truuslee

That's a tricky one, you made me dig but didn't find anything out of the box. In this thread they recommend to create a custom normalizer for such cases. https://github.com/api-plat...

I hope it helps. Cheers!

Cat in space

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

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

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9