Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

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