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 SubscribeI'm going to try out the GET one treasure endpoint... using a real id. Perfect. Because of the changes we just made, the owner
field is embedded.
What about changing the owner? Piece of crumb cake: as long as the field is writable... which ours is. Right now the owner
is id 1. Use the PUT endpoint to update id 2. For the payload, set owner
to /api/users/3
.
And... execute! Bah! Syntax error. JSON is crabby. Remove the comma, try again and... yes! The owner
comes back as the IRI /api/users/3
.
But now I want to do something wild! This treasure is owned by user 3. Let's go get their details. Open the GET one user endpoint, try it out, enter 3 and... there it is! The username is burnout400
.
Here's the goal: while updating a DragonTreasure
- so while using the PUT endpoint to /api/treasures/{id}
- instead of changing from one owner to another, I want to change the existing owner's username
. Something like this: instead of setting owner
to the IRI string, set it to an object with username
assigned to something new.
Would that work? Let's experiment! Hit Execute and it does not. It says:
Nested documents for attribute
owner
are not allowed, use IRI instead.
So, at first glance, it looks like this isn't allowed: it looks like you can only use an IRI string here. But actually, this is allowed. The problem is that the username
field is not writable via this operation.
Let's think about this. We're updating a DragonTreasure
. This means that API Platform is using the treasure:write
serialization group. That group is above the owner
property, which is why we can change the owner
.
... lines 1 - 25 | |
( | |
... lines 27 - 49 | |
denormalizationContext: [ | |
'groups' => ['treasure:write'], | |
], | |
... line 53 | |
) | |
... line 55 | |
class DragonTreasure | |
{ | |
... lines 58 - 99 | |
'treasure:read', 'treasure:write']) ([ | |
private ?User $owner = null; | |
... lines 102 - 213 | |
} |
But if we want to be able to change the owner's username
, then we also need to go into User
and add that group here.
... lines 1 - 22 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 25 - 46 | |
'user:read', 'user:write', 'treasure:item:get', 'treasure:write']) ([ | |
... line 48 | |
private ?string $username = null; | |
... lines 50 - 170 | |
} |
This works exactly like embedded fields when we read them. Basically, since at least one field in User
has the treasure:write
group, we are now allowed to send an object to the owner
field.
Watch: fire it up again. It works... almost. We get a 500 error:
A new entity was found through the relationship
DragonTreasure.owner
, but was not configured tocascade
persist.
Woh. This means that the serializer saw our data, created a new User
object and then set the username
onto it. Doctrine failed because we never told it to persist the new User
object.
Though... that's not the point: the point is that we don't want a new User
! We want to grab the existing owner and update its username
.
By the way, to make this example more realistic, let's also add a name
to the payload so we can pretend that we're actually updating the treasure... and decide to also update the username
of the owner while we're in the neighborhood.
Anyways: how do we tell the serializer to use the existing owner instead of creating a new one? By adding an @id
field set to the IRI of the user: /api/users/3
.
That's it! When the serializer sees an object, if it does not have an @id
, it creates a new object. If it does have an @id
, it finds that object and then sets any data onto it.
So, moment of truth. When we try it... of course, another syntax error. Get it together Ryan! After fixing that... perfect! A 200 status code! Though... we can't really see if it updated the username
here... since it just shows the owner.
Use the GET one User
endpoint... find user 3... and check that sweet data! It did change the username
.
Ok, so I realize that this example may not have been the most realistic, but being able to update related objects does have plenty of real use-cases.
Looking back at that PUT
request, what if we did want to allow a new User
object to be created and saved? Is that possible? It is!
First, we would need to add a cascade:
['persist'] to the treasure.owner
ORM\Column
attribute. This is something we'll see later. And second, we would need to make sure to expose all of the required fields as writable. Right now only username
is writable... so we couldn't send password
or email
.
Before we keep going, we are missing one small, but important, detail. Let's try this update one more time with the @id
. But set username
to an empty string.
Remember, the username
field has a NotBlank
above it, so this should fail validation. And yet, when we try it, we get a 200 status code! And if we go to the GET one user endpoint... yeah, the username
is now empty! That's... a problem.
How did that happen? Because of how Symfony's validation system works.
The top-level entity - the object that we're modifying directly - is DragonTreasure
. So the validation system looks at DragonTreasure
and it executes all of the validation constraints. However, when it gets to an object like the owner
property, it stops. It does not continue to validate that object as well.
If you want that to happen, you need to add a constraint to this called Assert\Valid
.
... lines 1 - 55 | |
class DragonTreasure | |
{ | |
... lines 58 - 100 | |
private ?User $owner = null; | |
... lines 103 - 214 | |
} |
Now... on our PUT endpoint... if we try this again, yep! 422: owner.username
, this value should not be blank.
Being able to update an embedded object is really neat & powerful. But the cost of this is making your API more and more complex. So while you can choose to do this - and you should if it's what you want - you might also choose to force the API client to update the treasure first... and then make a second request to update the user's username... instead of allowing them to do it all fancy at the same time.
Next: let's look at this relationship from the other side. When we're updating a User
, could we also update the treasures that belong to that user? Let's find out!
Hey,
IIRC if you are persisting a new object you don't need to set back relation to it, I mean you User payload should be like
{
"username": "userOne",
"profile": {
"info": "info about the user in profile",
}
}
That should be enough to create related objects at once.
Cheers!
Hey. Cool series.
I noticed a small issue
After successful update of username
, property name
of Treasure remained the same.
{
"name": "Brand new treasure!",
"owner": {
"@id": "/api/users/3",
"username": "Tom"
}
}
I can say even more. PUT request
{
"name": "Brand new treasure111!"
}
has response code:200, but name
is not changed.
When I want to change username and name of treasure I receive error: "hydra:description": "name: Describe your loot in 50 chars or less\ncoolFactor: This value should be less than or equal to 10.",
I know that is setted #[Assert\NotBlank] but it's just the same like you have code?
Hey @Szymon!
It looks like the data on that treasure is STARTING invalid - like the coolFactor in the database is somehow already greater than 10. And so, even though you’re not changing that field, it fails validation. I think for the treasure’s name I may have a bug on the fixtures that randomly sets a name that’s longer than 50 - so that is likely the cause of that error. I might (?) have some problem also with coolFactor!
So, just a data error on the database - and probably my fault. On production, thanks to validation, that data would never be able to get into this invalid state.
Cheers!
Yeah, that is the reason, changing the getDefaults()
in DragonTreasureFactory.php
to something like:
`protected function getDefaults(): array
{
return [
'coolFactor' => self::faker()->numberBetween(1, 10),
'description' => self::faker()->text(255),
'isPublished' => self::faker()->boolean(),
'name' => self::faker()->text(50),
'plunderedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'value' => self::faker()->randomNumber(),
'owner' => UserFactory::new(),
];
}`
will fix it.
Regards!
// 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
}
}
Hi guys but what's about the case when we have one-one relation, for example User-Profile.
We create a user with a profile, But the profile scope requires iri of the user /api/user/??? It works only if I have IRI user id. All relations has
cascade: ['persist']
idea how to solve it?