How to properly do OneToMany by using Doctrine
AuthorBornfight
DateJun 30, 2020
Using ORM frameworks like Doctrine can help us a great deal when writing small and medium-sized web applications.
It allows us to treat database rows like objects, therefore simplifying the process of 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 usual 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 the objects you get are a mirror image of the state of the database.
Let’s say we have a 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 these, we should ask ourselves: do we really need both directions? It is very unlikely that you will have an Address and need to fetch its User. If you do need that, you might have structural problems in your application. Address is meaningless without a User, so we should never work with it without first having a User.
We should avoid bidirectional relationships whenever possible. With Doctrine, it’s a bit trickier to do unidirectional OneToMany relation.
How not to do it
Let’s continue 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 a $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 now has a user_id column.
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 the Address! The only direction we can remove is the inverse one $user->getAddresses(). And this forces us to use a bidirectional relation that might cause performance problems on a hydration level as our database grows.
Now, 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 a OneToMany relation to the 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 a User but about a Company as well. And on top of that, without validation, the Address entity can have both User and Company relations at the same time. And we probably didn’t want that.
Our address table in the database now looks like this.
At this point, it’s obvious that this is not the way to go because Address is a value object and should not have references to other entities.
How to do it
To do unidirectional OneToMany properly, we should in fact use a ManyToMany relation with a 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 the ORM doesn’t have to perform unnecessary hydration. Also, adding another owner of the 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. And 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.
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.
And that’s it. That’s how you can properly do OneToMany by using Doctrine!