Doing OneToMany properly with Doctrine

markopaden

Marko Pađen

Posted on July 30, 2020

Doing OneToMany properly with Doctrine

Using ORM frameworks like Doctrine can help us a great deal when writing small to medium sized web applications. It allows us to treat database rows like objects, therefore simplifying working with them in object oriented programming languages. With ORM, we don't have to worry about things like fetching and saving since all of that happens "magically" behind the framework.

Why?

As always, simplifications and abstraction come at a cost - with ORMs it's usually performance. Without going too deep into it(there are several great articles that explain how ORM magic works), we can say that ORM needs to ensure that objects you get are a mirror image of the state of the database.

Let's say we have User entity that can have multiple Addresses. Fetching Users should also fetch their addresses, since we should be able to call $user->getAddresses(). It should never happen that this call returns no Addresses, if the user in fact has some Addresses stored. Therefore, ORM needs to do a bit more work than it is usually needed.

Modern ORMs usually do some sort of lazy loading to overcome that, but it is not always possible. Because of ORMs magic, you can make life really hard for yourself if you don't understand how it works. Properly setup relations are a key to any maintainable application.

ORM doesn't like bidirectional relationships. In the example above, the relation is bidirectional if you are able to call both
$user->getAddresses() and $address->getUser(). To make this possible, ORM needs to do what's called hydration. As our database grows, this process can become really expensive and slow down our application.

In cases like this, we should ask ourselves: Do we really need both directions? It is very unlikely that you will have Address and need to fetch its User. If you do need that, you might have structural problems in your application. Address is meaningless without User, so we should never work with it without first having User.

We should avoid bidirectional relationships whenever possible. With Doctrine, it's a bit trickier to do unidirectional OneToMany relation.

How not to.

Working with the example above, we have Address and User entities.

If we do OneToMany the normal way, it is always bidirectional.

<?php
class User
{

    //fields

    /**
     * @var Collection[]
     * @ORM\OneToMany(targetEntity=Address::class, mappedBy="user", orphanRemoval=true,  cascade={"persist", "remove"})
     */
    private $addresses;
}

Our Address entity now needs to have $user field.

<?php
class Address
{

    //fields

    /**
     * @var User|null
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="addresses")
     */
    private $user;
}

Looking at the database, we can see that our Address table has user_id column.

id country_id street city postal_code user_id

This setup might look perfectly fine, but there are a couple of big problems. We can see that in this case, the owner of the relation is actually Address! The only direction we can remove is the inverse one $user->getAddresses(). This forces us to use a bidirectional relation that might cause performance problems on hydration level as our database grows.

What if we want to reuse our Address model? Let's say we have a Company entity that can also have multiple Addresses. We can add OneToMany relation to Company as well.

<?php
Address
{

    //fields

    /**
     * @var User|null
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="addresses")
     */
    private $user;

    /**
     * @var Company|null
     * @ORM\ManyToOne(targetEntity=Company::class, inversedBy="addresses")
     */
    private $company;
}

Suddenly, our address not only knows about User, but about Company as well! On top of that, without validation Address
entity can have both User and Company relations at the same time. We probably didn't want that.

Our address table in the database now looks like this.

id country_id street city postal_code user_id company_id

It should be obvious at this point that this is not the way to go. Address is a value object and should not have references to other entities.

How?

To do unidirectional OneToMany properly, we should in fact use ManyToMany relation with unique constraint.

<?php
class User
{

    //fields

    /**
     * @var Collection[]
     * @ORM\ManyToMany(targetEntity=Address::class, cascade={"persist", "remove"}, orphanRemoval=true)
     * @ORM\JoinTable(name="user_addresses",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="cascade")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="address_id", referencedColumnName="id", unique=true)}
     *      )
     */
    private $addresses;
}

Now, our Address entity does not have to know about its owners. That information is stored in the join table and ORM doesn't have to perform unnecessary hydration. Also, adding another owner of Address entity does not impact the Address table or entity.

We shouldn't be afraid of the join table as it is hidden from us by the ORM. Performance wise, joining tables by integer foreign keys is a relatively cheap operation for our database.

Our Address entity now looks like this.

<?php


class Address
{
    /**
     * @var int|null
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    private $street;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    private $city;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    private $postalCode;

    /**
     * @var Country
     * @ORM\ManyToOne(targetEntity=Country::class)
     * @ORM\JoinColumn(nullable=false)
     */
    private $country;
}

And our address table doesn't have any foreign keys.

id country_id street city postal_code

We can use our entities the same way we did before, just without unnecessary bidirectional relation. Later on if we ever need the other direction, we can add it to our code without changing our database structure.

Do you smell the cleanliness of this solution? :)
Are you using unidirectional relations in your applications?

💖 💪 🙅 🚩
markopaden
Marko Pađen

Posted on July 30, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related