If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeInstead 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.
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
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... 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!
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!
Hey triemli!
Hmm, is that right? It's certainly possible, but I would be surprised by that. Is it possible that, instead of the field being incorrectly named, there is an issue with the denormalization groups - i.e. so that the setTextDescription()
in this User embedded situation is not writable (and so the serializer is simply ignoring the description
field and then, since the description
property is blank, it fails validation)?
Let me know - I'm curious :).
Cheers!
If I use the next data direct to CheeseListing, so it works:
{
"title": "new cheese",
"price": 4000,
"description": "description cheese"
}
It doesn't work only in case of embedded.
#[ORM\Column(type: Types::TEXT)]
#[Groups(['cheese_listing:read', 'user:read', 'user:write'])]
#[NotBlank]
private ?string $description = null;
#[Groups(['cheese_listing:write'])]
#[SerializedName('description')]
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
Also it works if I just add setDescription()
method. So I can make conclusion that #[SerializedName('description')]
don't goes being embedded.
Hey @triemli!
Hmm. What happens if you add the user:write
group to the setTextDescription()
method? I believe that's needed. By using setTextDescription()
to set the description
property (instead of the normal setDescription()
), when a description
property is sent to the API, API Platform sees that setTextDescription()
should be used and then reads its Groups
. It actually doesn't know or care that the description
property will ultimately be set. And so, it doesn't read the property's groups.
Anyways, let me know if that fixes it - I hope so. And happy new year!
Cheers!
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.
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.
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!
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"
}
]
}'
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"
]
]
}
]
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!!
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!!!
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!
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 ?
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!
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.
Hey Auro
I believe this other tutorial may give you good ideas of how to do so
https://symfonycasts.com/sc...
Cheers!
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"
]
}<br />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).<br />Resonse would look like this:<br />
{
...
"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.
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!
// 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
}
}
Curious that SerializedName('description') annotation for setTextDescription method in embedded case doesn't work. Just got when description is filled:
request: