Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Relating Resources

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

In 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.

Adding the ManyToOne Relation

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)) {
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) {
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('CREATE INDEX IDX_9E31BF5F7E3C61F9 ON dragon_treasure (owner_id)');
... lines 27 - 35

Run it with:

symfony console doctrine:migrations:migrate

Resetting the Database

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


symfony console doctrine:database:create

And the migration, which should work now that our database is empty.

symfony console doctrine:migrations:migrate

Setting up the Fixtures

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
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 DragonTreasures each randomly hoarded by one of these 10 Users.

Let's try it! Run:

symfony console doctrine:fixtures:load

This time... success!

Exposing the "owner" in the API

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
#[Groups(['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!

Writing a Relation Property

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.

Leave a comment!

Login or Register to join the conversation
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