Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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

Is it possible to create a totally new DragonTreasure when we create a user? Like... instead of sending the IRI of an existing treasure, we send an object?

Let's try it! First, I'll change this to a unique email and username. Then, for dragonTreasures, clear those IRIs and, instead, pass a JSON object with the fields that we know are required. Our new dragon user just scored a copy of GoldenEye for N64! Legendary. Add a description... and a value.

In theory, this JSON body makes sense! But does it work? Hit "Execute" and... nope! Well, not yet. But we know this error!

Nested documents for attribute dragonTreasures are not allowed. Use IRIs instead.

Making dragonTreasures Accept JSON Objects

Inside User, if we scroll way up, the $dragonTreasures property is writable because it has user:write.

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 25 - 51
#[Groups(['user:read', 'user:write'])]
private Collection $dragonTreasures;
... lines 54 - 170
}

But we can't send an object for this property because we haven't added user:write to any of the fields inside of DragonTreasure. Let's fix that.

We want to be able to send $name, so add user:write... I'll skip $description but do the same for $value. Now search for setTextDescription() which is the actual description. Add user:write here too.

... lines 1 - 55
class DragonTreasure
{
... lines 58 - 63
#[Groups(['treasure:read', 'treasure:write', 'user:read', 'user:write'])]
... lines 65 - 67
private ?string $name = null;
... lines 69 - 79
#[Groups(['treasure:read', 'treasure:write', 'user:read', 'user:write'])]
... lines 81 - 82
private ?int $value = 0;
... lines 84 - 138
#[Groups(['treasure:write', 'user:write'])]
public function setTextDescription(string $description): self
{
... lines 142 - 144
}
... lines 146 - 214
}

Okay, in theory, we should now be able to send an embedded object. If we head over and try it again... we upgraded to a 500 error!

A new entity was found through the relationship User#dragonTreasures

Cascading an Entity Relation Persist

This is great! We already know that when you send an embedded object, if you include @id, the serializer will fetch that object first and then update it. But if you don't have an @id, it will create a brand new object. Right now, it is creating a new object,... but nothing told the entity manager to persist it. That's why we're getting this error.

To solve this, we need to cascade persist this property. In User, on the OneToMany for $dragonTreasures, add a cascade option set to ['persist'].

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 25 - 50
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: DragonTreasure::class, cascade: ['persist'])]
... line 52
private Collection $dragonTreasures;
... lines 54 - 170
}

This means that if we're saving a User object, it should magically persist any $dragonTreasures inside. And if we try it now... it works! That's awesome! And apparently, our new treasure id is 43.

Let's open up a new browser tab and navigate to that URL... plus .json... actually, let's do .jsonld. Beautiful! We see that the owner is set to the new user that we just created.

How was owner Set? Again: The Smart Methods

But... hold your horses! We didn't send the owner field in the treasure data... so how did that field get set? Well, first, it does make sense that we didn't send an owner field for the new DragonTreasure... since the user that will own it didn't even exist yet! Ok, then, but who did set the owner?

Behind the scenes, the serializer creates a new User object first. Then, it creates a new DragonTreasure object. Finally, it sees that the new DragonTreasure is not assigned to the User yet, and it calls addDragonTreasure(). When it does that, the code down here sets the owner: just like we saw before. So our well-written code is taking care of all of those details for us.

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 25 - 149
public function addDragonTreasure(DragonTreasure $treasure): self
{
if (!$this->dragonTreasures->contains($treasure)) {
$this->dragonTreasures->add($treasure);
$treasure->setOwner($this);
}
return $this;
}
... lines 159 - 170
}

Adding the Valid Constraint

Anyways, you might remember from before that as soon as we allow a relation field to send embedded data... we need to add one tiny thing. I won't do it, but if we sent an empty name field, it would create a DragonTreasure... with an empty name, even though, over here, if we scroll up to the name property, it's required! Remember: when the system validates the User object, it will stop at $dragonTreasures. It won't also validate those objects. If you do want to validate them, add #[Assert\Valid].

... lines 1 - 22
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 25 - 52
#[Assert\Valid]
private Collection $dragonTreasures;
... lines 55 - 171
}

Now that I have this, to prove that it's working, hit "Execute" and... awesome! We get a 422 status code telling us that name shouldn't be empty. I'll go put that back.

Sending Embedded Objects and IRI Strings at the Same Time

We now know that we can send IRI strings or embedded objects for a relation property - assuming we've setup the serialization groups to allow that. And, we can even mix them.

Let's say that we want to create a new DragonTreasure object, but we're also going to steal, borrow, a treasure from another dragon. This is totally allowed. Watch! When we hit "Execute"... we get a 201 status code. This returns treasure ids 44 (that's the new one) and 7, which is the one we just stole.

Okay, we only have one more chapter about handling relationships. Let's see how we can remove a treasure from a user to delete that treasure. That's next.

Leave a comment!

2
Login or Register to join the conversation
Jeremy Avatar

Hi!

How can you prevent from stealing while allowing to edit collections like you did here?
For now, I prevent writing in collections like this (when there is ownership-like stuff) and manage to handle it in opposite side.

Cheers

Reply

Hey @Jeremy!

For now, I prevent writing in collections like this (when there is ownership-like stuff) and manage to handle it in opposite side.

First, good job spotting this potential issue! Honestly, what you said is the safest and simplest way. We CAN prevent stealing, but it adds complexity (both to the code and... just to my brain, lol). We talk about how to prevent stealing in the next tutorial - https://symfonycasts.com/screencast/api-platform-security/unit-of-work-validator

Cheers!

Reply
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
    }
}
userVoice