Creating Embedded Objects

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

Instead of assigning an existing CheeseListing to the user, could we create a totally new one by embedding its data? Let's find out!

This time, we won't send an IRI string, we'll send an object of data. Let's see... we need a title and... I'll cheat and look at the POST endpoint for cheeses. Right: we need title, price owner and description. Set price to 20 bucks and pass a description. But I'm not going to send an owner property. Why? Well... forget about API Platform and just imagine you're using this API. If we're sending a POST request to /api/users to create a new user... isn't it pretty obvious that we want the new cheese listing to be owned by this new user? Of course, it's our job to actually make this work, but this is how I would want it to work.

Oh, and before we try this, change the email and username to make sure they're unique in the database.

Ready? Execute! It works! No no, I'm totally lying - it's not that easy. We've got a familiar error:

Nested documents for attribute "cheeseListings" are not allowed. Use IRIs instead.

Allowing Embedded cheeseListings to be Denormalized

Ok, let's back up. The cheeseListings field is writable in our API because the cheeseListings property has the user:write group above it. But if we did nothing else, this would mean that we can pass an array of IRIs to this property, but not a JSON object of embedded data.

To allow that, we need to go into CheeseListing and add that user:write group to all the properties that we want to allow to be passed. For example, we know that, in order to create a CheeseListing, we need to be able to set title, description and price. So, let's add that group! user:write above title, price and... down here, look for setTextDescription()... and add it there.

... lines 1 - 39
class CheeseListing
{
... lines 42 - 48
/**
... line 50
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"})
... lines 52 - 57
*/
private $title;
... lines 60 - 67
/**
... lines 69 - 71
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"})
... line 73
*/
private $price;
... lines 76 - 134
/**
... lines 136 - 137
* @Groups({"cheese_listing:write", "user:write"})
... line 139
*/
public function setTextDescription(string $description): self
... lines 142 - 197
}

I love how clean it is to choose which fields you want to allow to be embedded... but life is getting more complicated. Just keep that "complexity" cost in mind if you decide to support this kind of stuff in your API

Cascade Persist

Anyways, let's try it! Ooh - a 500 error. We're closer! And we know this error too!

A new entity was found through the User.cheeseListings relation that was not configured to cascade persist.

Excellent! This tells me that API Platform is creating a new CheeseListing and it is setting it onto the cheeseListings property of the new User. But nothing ever calls $entityManager->persist() on that new CheeseListing, which is why Doctrine isn't sure what to do when trying to save the User.

If this were a traditional Symfony app where I'm personally writing the code to create and save these objects, I'd probably just find where that CheeseListing is being created and call $entityManager->persist() on it. But because API Platform is handling all of that for us, we can use a different solution.

Open User, find the $cheeseListings property, and add cascade={"persist"}. Thanks to this, whenever a User is persisted, Doctrine will automatically persist any CheeseListing objects in this collection.

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"})
... line 61
*/
private $cheeseListings;
... lines 64 - 184
}

Ok, let's see what happens. Execute! Woh, it worked! This created a new User, a new CheeseListing and linked them together in the database.

But who set CheeseListing.owner?

But... how did Doctrine... or API Platform know to set the owner property on the new CheeseListing to the new User... if we didn't pass an owner key in the JSON? If you create a CheeseListing the normal way, that's totally required!

This works... not because of any API Platform or Doctrine magic, but thanks to some good, old-fashioned, well-written code in our entity. Internally, the serializer instantiated a new CheeseListing, set data on it and then called $user->addCheeseListing(), passing that new object as the argument. And that code takes care of calling$cheeseListing->setOwner() and setting it to $this User. I love that: our generated code from make:entity and the serializer are working together. What's gonna work? Team work!

Embedded Validation

But, like when we embedded the owner data while editing a CheeseListing, when you allow embedded resources to be changed or created like this, you need to pay special attention to validation. For example, change the email and username so they're unique again. This is now a valid user. But set the title of the CheeseListing to an empty string. Will validation stop this?

Nope! It allowed the CheeseListing to save with no title, even though we have validation to prevent that! That's because, as we talked about earlier, when the validator processes the User object, it doesn't automatically cascade down into the cheeseListings array and also validate those objects. You can force that by adding @Assert\Valid().

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
... lines 60 - 61
* @Assert\Valid()
*/
private $cheeseListings;
... lines 65 - 185
}

Let's make sure that did the trick: go back up, bump the email and username to be unique again and... Execute! Perfect! A 400 status code because:

the cheeseListings[0].title field should not be blank.

Ok, we've talked about how to add new cheese listings to an user - either by passing the IRI of an existing CheeseListing or embedding data to create a new CheeseListing. But what would happen if a user had 2 cheese listings... and we made a request to edit that User... and only included the IRI of one of those listings? That should... remove the missing CheeseListing from the user, right? Does that work? And if so, does it set that CheeseListing's owner to null? Or does it delete it entirely? Let's find some answers next!

Leave a comment!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.19.2
        "symfony/console": "4.2.*", // v4.2.9
        "symfony/dotenv": "4.2.*", // v4.2.9
        "symfony/flex": "^1.1", // v1.9.6
        "symfony/framework-bundle": "4.2.*", // v4.2.9
        "symfony/yaml": "4.2.*" // v4.2.9
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/profiler-pack": "^1.0" // v1.0.4
    }
}