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 SubscribeIn our app, each DragonTreasure
should be owned by a single dragon... or User
in our system. To set this up, forget about the API for a moment and let's just model this in the database.
Spin over to your terminal and run:
php bin/console make:entity
Let's modify the DragonTreasure
entity to add an owner
property... and then this will be a ManyToOne
relation. If you're not sure which relation you need, you can always type relation
and get a nice little wizard.
This will be a relation to User
... and then it asks if the new owner
property is allowed to be null in the database. Every DragonTreasure
must have an owner... so say "no". Next: do we we want to map the other side of the relationship? So basically, do we want the ability to say, $user->getDragonTreasures()
in our code? I'm going to say yes to this. And you might answer "yes" for two reasons. Either because being able to say $user->getDragonTreasures()
would be useful in your code or, as we'll see a bit later, because you want to be able to fetch a User
in your API and instantly see what treasures it has.
Anyways, the property - dragonTreasures
inside of User
is fine.... and finally, for orphanRemoval
, say no. We'll also talk about that later.
And... done! Hit enter to exit.
So this had nothing to do with API Platform. Our DragonTreasure
entity now has a new owner
property with getOwner()
and setOwner()
methods.
... lines 1 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 93 | |
#[ORM\ManyToOne(inversedBy: 'dragonTreasures')] | |
#[ORM\JoinColumn(nullable: false)] | |
private ?User $owner = null; | |
... lines 97 - 197 | |
public function getOwner(): ?User | |
{ | |
return $this->owner; | |
} | |
public function setOwner(?User $owner): self | |
{ | |
$this->owner = $owner; | |
return $this; | |
} | |
} |
And over in User
we have a new dragonTreasures
property, which is a OneToMany
back to DragonTreasure
. At the bottom, it generated getDragonTreasures()
, addDragonTreasure()
, and removeDragonTreasure()
. Very standard stuff.
... lines 1 - 6 | |
use Doctrine\Common\Collections\ArrayCollection; | |
use Doctrine\Common\Collections\Collection; | |
... lines 9 - 22 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 25 - 50 | |
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: DragonTreasure::class)] | |
private Collection $dragonTreasures; | |
public function __construct() | |
{ | |
$this->dragonTreasures = new ArrayCollection(); | |
} | |
... lines 58 - 140 | |
/** | |
* @return Collection<int, DragonTreasure> | |
*/ | |
public function getDragonTreasures(): Collection | |
{ | |
return $this->dragonTreasures; | |
} | |
public function addDragonTreasure(DragonTreasure $treasure): self | |
{ | |
if (!$this->dragonTreasures->contains($treasure)) { | |
$this->dragonTreasures->add($treasure); | |
$treasure->setOwner($this); | |
} | |
return $this; | |
} | |
public function removeDragonTreasure(DragonTreasure $treasure): self | |
{ | |
if ($this->dragonTreasures->removeElement($treasure)) { | |
// set the owning side to null (unless already changed) | |
if ($treasure->getOwner() === $this) { | |
$treasure->setOwner(null); | |
} | |
} | |
return $this; | |
} | |
} |
Let's create a migration for this:
symfony console make:migration
We'll do our standard double-check to make sure the migration isn't trying to mine bitcoin. Yep, all boring SQL queries here.
... lines 1 - 12 | |
final class Version20230104200643 extends AbstractMigration | |
{ | |
... lines 15 - 19 | |
public function up(Schema $schema): void | |
{ | |
// this up() migration is auto-generated, please modify it to your needs | |
$this->addSql('ALTER TABLE dragon_treasure ADD owner_id INT NOT NULL'); | |
$this->addSql('ALTER TABLE dragon_treasure ADD CONSTRAINT FK_9E31BF5F7E3C61F9 FOREIGN KEY (owner_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); | |
$this->addSql('CREATE INDEX IDX_9E31BF5F7E3C61F9 ON dragon_treasure (owner_id)'); | |
} | |
... lines 27 - 35 | |
} |
Run it with:
symfony console doctrine:migrations:migrate
And it explodes in our face. Rude! But... it shouldn't be too surprising. We already have about 40 DragonTreasure
records in our database. So when the migration tries to add the owner_id
column to the table - which does not allow null - our database is stumped: it has no idea what value to put for those existing treasures.
If our app were already on production, we'd have to do a bit more work to fix this. We talk about that in our Doctrine tutorial. But since this isn't on production, we can cheat and just to turn the database off and on again. To do that run:
symfony console doctrine:database:drop --force
Then:
symfony console doctrine:database:create
And the migration, which should work now that our database is empty.
symfony console doctrine:migrations:migrate
Finally, re-add some data with:
symfony console doctrine:fixtures:load
And oh, this fails for the same reason! It's trying to create Dragon Treasures without an owner. To fix that, there are two options. In DragonTreasureFactory
, add a new owner
field to getDefaults()
set to UserFactory::new()
.
... lines 1 - 29 | |
final class DragonTreasureFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
... lines 50 - 55 | |
'owner' => UserFactory::new(), | |
]; | |
} | |
... lines 59 - 73 | |
} |
I'm not going to go into the specifics of Foundry - and Foundry has great docs on how to work with relationships - but this will create a new User
each time it creates a new DragonTreasure
... and then will relate them. So that's nice to have as a default.
But in AppFixtures
, let's override that to do something cooler. Move the DragonTreasureFactory
call after UserFactory
... then pass a second argument, which is a way to override the defaults. By passing a callback, each time a DragonTreasure
is created - so 40 times - it will call this method and we can return unique data to use for overriding the defaults for that treasure. Return owner
set to User::factory()->random()
.
... lines 1 - 9 | |
class AppFixtures extends Fixture | |
{ | |
public function load(ObjectManager $manager): void | |
{ | |
UserFactory::createMany(10); | |
DragonTreasureFactory::createMany(40, function () { | |
return [ | |
'owner' => UserFactory::random(), | |
]; | |
}); | |
} | |
} |
That'll find a random User
object and set it as the owner
. So we'll have 40 DragonTreasure
s each randomly hoarded by one of these 10 User
s.
Let's try it! Run:
symfony console doctrine:fixtures:load
This time... success!
Ok, so now DragonTreasure
has a new owner
relation property... and User
has a new dragonTreasures
relation property.
Will... that new owner
property show up in the API? Try the GET collection endpoint for treasure. And... the new field does not show up! That makes sense! The owner
property is not inside the normalization group.
So if we want to expose the owner
property in the API, just like any other field, we need to add groups to it. Copy the groups from coolFactor
... and paste them here.
... lines 1 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 95 | |
'treasure:read', 'treasure:write']) ([ | |
private ?User $owner = null; | |
... lines 98 - 209 | |
} |
This makes the property readable and writable. And yes, later, we'll learn how to set the owner
property automatically so that the API user doesn't need to send that manually. But for now, having the API client send the owner
field will work great.
Anyways, what does this new owner
property look like? Hit "Execute" and... woh! The owner
property is set to a URL! Well, really, the IRI of the User
.
I love this. When I first started working with API Platform, I thought relationship properties might just use the object's id. Like owner: 1
. But this is way more useful... because it tells our API client exactly how they could get more information about this user: just follow the URL!
So, by default, a relation is returned as a URL. But what does it look like to set a relation field? Refresh the page, open the POST endpoint, try it, and I'll paste in all of the fields except for owner
. What do we use for owner
? I don't know! Let's try setting it to an id, like 1
.
Moment of truth. Hit execute. Let's see... a 400 status code! And check out the error:
Expected IRI or nested document for attribute
owner
, integer given.
So I passed the ID
of the owner and... it doesn't like that. What should we put here? Well, the IRI of course! Let's find out more about that next.
"Houston: no signs of life"
Start the conversation!
// 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
}
}