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

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!

15
Login or Register to join the conversation
Musa Avatar

Just a little side-note if anyone runs into the same issue I did.
The owning side must contain all autogenerated Entity field methods for this to work (at least addEntity and removeEntity method).
In my use case removeEntity was just in the way due to the setEntity(null) issue on required relation.

Expected behaviour (IMO) was for doctrine to complain about cascade persist (2:49) or tell me a required function is missing, but instead it ignored the addEntity method all together and threw no errors.

Reply
Andrew M. Avatar
Andrew M. Avatar Andrew M. | posted 11 months ago

I'm getting "Cannot create metadata for non-objects." I'm using Symfony 5 here. Not sure where to go with this, Google searches are not helping.

Reply

Hey Andrew M.!

Hmm, this is a new one for me too! It looks like this is probably coming from deep in the serializer. Do you have a stack trace on this? What request are you making (e.g. a GET request to some object)? Is there any way to see the data that's being serialized?

Cheers!

Reply
Andrew M. Avatar

Here's the request:


curl -X 'POST' \
'https://127.0.0.1:8000/api/users' \
-H 'accept: application/ld+json' \
-H 'Content-Type: application/ld+json' \
-d '{
"email": "use065465r@example.com",
"password": "654654",
"username": "2254455",
"cheeseListings": [
{
"title": "Cheese 654654",
"price": 6587,
"description": "descknjdn"
}
]
}'
Reply
Andrew M. Avatar

Hi, thanks for the reply. Here's the full trace; it's pretty long.

{
"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Cannot create metadata for non-objects. Got: \"string\".",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 653,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateGenericNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 516,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateClassNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 313,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateObject",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 138,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveValidator.php",
"line": 93,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/TraceableValidator.php",
"line": 66,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "TraceableValidator",
"class": "Symfony\\Component\\Validator\\Validator\\TraceableValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Bridge/Symfony/Validator/Validator.php",
"line": 67,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator",
"short_class": "Validator",
"class": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator\\Validator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Validator/EventListener/ValidateListener.php",
"line": 68,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Validator\\EventListener",
"short_class": "ValidateListener",
"class": "ApiPlatform\\Core\\Validator\\EventListener\\ValidateListener",
"type": "->",
"function": "onKernelView",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/WrappedListener.php",
"line": 117,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "WrappedListener",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener",
"type": "->",
"function": "__invoke",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 230,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "callListeners",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 59,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php",
"line": 151,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "TraceableEventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 161,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handleRaw",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 78,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/Kernel.php",
"line": 199,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "Kernel",
"class": "Symfony\\Component\\HttpKernel\\Kernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php",
"line": 37,
"args": []
},
{
"namespace": "Symfony\\Component\\Runtime\\Runner\\Symfony",
"short_class": "HttpKernelRunner",
"class": "Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner",
"type": "->",
"function": "run",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php",
"line": 35,
"args": []
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "require_once",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/public/index.php",
"line": 5,
"args": [
[
"string",
"/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php"
]
]
}
]
}
Reply

Hey Andrew M.!

Ok, I think I know what's going on... and you already figured it out :). Remove @Assert\Valid from above the username property. @Assert\Valid is only needed/used when a property is an *object*. It tells the validator system to recursively make sure that the object is ALSO valid. It's meaningless (and in fact gives you this error) if it's applied to a non-object property. For your username property, just put the normal NotBlank type of constraints that you need on it.

Let me know if that makes sense!

Cheers!!

2 Reply
Andrew M. Avatar

That's great, I understand, thanks.

Reply

Hello Ryan!
I'm trying to make an embedded entity but with a non-doctrine object

How could I create a form with Embedding custom DTO Object property (This object is not a doctrine entity)?
For example:

Dto model:


class Point
{
/**
* @Groups({"point:write"})
*/
public $latitude;

/**
* @Groups({"point:write"})
*/
public $longitude;
}

Entity //@ORM\Entity


class City
{
....
private $point;
}

Greetings!!!

Reply

Hey Juan,

To create a form - you need to use a custom form type for that "City::$point" field, that will contain 2 text fields: one for $latitude and one for $longitude. Then, Symfony Form component will know how to render that form, and how to read/write those values. But you will still need to think about how to store that non-doctrine object, probably you will need to use a serialization or json encode for that field - Doctrine has corresponding field types for this.

Cheers!

1 Reply
Vishal T. Avatar
Vishal T. Avatar Vishal T. | posted 1 year ago

Hi Ryan,

I am having two entity with OneToOne relationship.

 
class User
{
/**
* @ORM\OneToOne(targetEntity=ClientProfile::class, mappedBy="user", cascade={"persist", "remove"})
* @Groups({"user:write"})
*/
private $clientProfile;
}

class ClientProfile
{
/**
* @ORM\OneToOne(targetEntity=User::class, inversedBy="clientProfile", cascade={"persist", "remove"})
*/
private $user;

/**
* @ORM\Column(type="string")
* @Groups({"user:write"})
*/
private $test;
}

so can we add embedded field test inside "User" table ?

Reply

Hi Vishal T.!

Sorry for the slow reply!

> so can we add embedded field test inside "User" table ?

I think you are asking whether or not you can make a POST /api/users request and send { "clientProfile": { "test": "foo" } } as the JSON so that you can change the embedded "test" property when updating/creating a user. Is this correct?

If so... then... yea! You have user:write groups on both User.clientProfile and ClientProfile.test, so you should be able to write that field in an embedded way. The interactive documentation should also reflect that fact. You would, of course, need methods like getClientProfile(), setClientProfile() and setTest(0 to make it work, but I think you are just not showing those to keep things short :).

Is this not working for you? If so, let me know what's going on - like any errors you are seeing.

Cheers!

Reply
Auro Avatar

Hi Ryan, thank you for this awesome serie of tutorials, really helpful.

Could you please give an example on how to accomplish this using DTO ?

I'm trying to adapt your code to create an Order entity that embed OrderItems, but I'm struggle.

Reply
MolloKhan Avatar MolloKhan | SFCASTS | Auro | posted 1 year ago | edited

Hey Auro

I believe this other tutorial may give you good ideas of how to do so
https://symfonycasts.com/sc...

Cheers!

Reply
Christopher hoyos Avatar
Christopher hoyos Avatar Christopher hoyos | posted 3 years ago

Is it possible to update individual items in a collection during a PUT operation? Similar to how the CollectionType Field would updated enities if the id was present.

The body of my request (PUT /resource/uri/1) follows this structure:

{
"a_prop": "some value",
"collection": [
"@id": "/another_resources/uri/1",
"another_prop": "new value"
]
}

The response will always return new uri for the item in the collection that is been updated (the previous one is getting deleted form db).
Resonse would look like this:

{
...
"collection": [
"@id": "/another_resources/uri/2", // new uri
"another_prop": "new value" // Updated value
]
}

I know the scenerario might be a little odd, but its all part of a old big form which the user may need to go back to and edit after it was already persisted to db. I'm considering splitting up the form to handle edits on a diferent view, but if possible i would like to avoid it. I wonder if its a configuration that i am missing (similar to the allow_add, allow_remove on the CollectionType), or is just not possible.

Reply

Hey Christopher hoyos!

Ha! Nice question. Tough question :). I think the answer is... yes! Um, maybe :P.

So, I tried this using this tutorial. Specifically, a made a PUT request to /api/users/8 and tried to update the "price" field on an existing cheeseListing with this body


{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
}
]
}

This DOES work. Assuming you've got all of your serialization groups set up (in this case, a user:write group is used when deserializing a User object and I've also added that same group to the CheeseListing.price property so that it can be updated), then it works just fine. This is a bit of a different result than you were getting... and I'm not sure why (well, your request and response body didn't look quite right to me - there should be an extra { } around each item inside the collection [] but I wasn't sure if that was a typo).

But, apart from needing the groups to be set up correctly, there is one catch: if the User has 2 cheese listings and you only want to edit one of them, you'll need to make sure you include ALL the cheese listings in the request, else the others will be removed. Something like this:


{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
},
"/api/cheeses/4"
]
}

Personally, to manage complexity, I'd *prefer* to update these individual cheese listings by making a PUT request to /api/cheeses/1 instead of trying to do it all inside on request to update the user. But, I also understand that if you're refactoring a giant form... it may be more natural to combine it all at once. But, it's something to think about :). And now that your form is submitting via JavaScript, you could even start updating each CheeseListing (or whatever your other resource really is) on "blur" - i.e. when the user clicks off a field, send a PUT request right then to update just that one item.

Cheers!

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