How to Work with Doctrine Associations / Relations
Screencast
Do you prefer video tutorials? Check out the Mastering Doctrine Relations screencast series.
There are two main relationship/association types:
ManyToOne
/OneToMany
-
The most common relationship, mapped in the database with a foreign
key column (e.g. a
category_id
column on theproduct
table). This is actually only one association type, but seen from the two different sides of the relation. ManyToMany
- Uses a join table and is needed when both sides of the relationship can have many of the other side (e.g. "students" and "classes": each student is in many classes, and each class has many students).
First, you need to determine which relationship to use. If both sides of the relation
will contain many of the other side (e.g. "students" and "classes"), you need a
ManyToMany
relation. Otherwise, you likely need a ManyToOne
.
Tip
There is also a OneToOne relationship (e.g. one User has one Profile and vice
versa). In practice, using this is similar to ManyToOne
.
The ManyToOne / OneToMany Association
Suppose that each product in your application belongs to exactly one category.
In this case, you'll need a Category
class, and a way to relate a
Product
object to a Category
object.
Start by creating a Category
entity with a name
field:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
$ php bin/console make:entity Category
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
This will generate your new entity class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Entity/Category.php
namespace App\Entity;
// ...
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private $id;
#[ORM\Column]
private string $name;
// ... getters and setters
}
Tip
Starting in MakerBundle: v1.57.0 - You can pass either --with-uuid
or
--with-ulid
to make:entity
. Leveraging Symfony's Uid Component,
this generates an entity with the id
type as Uuid
or Ulid instead of int
.
Mapping the ManyToOne Relationship
In this example, each category can be associated with many products. But, each product can be associated with only one category. This relationship can be summarized as: many products to one category (or equivalently, one category to many products).
From the perspective of the Product
entity, this is a many-to-one relationship.
From the perspective of the Category
entity, this is a one-to-many relationship.
To map this, first create a category
property on the Product
class with
the ManyToOne
attribute. You can do this by hand, or by using the make:entity
command, which will ask you several questions about your relationship. If you're
not sure of the answer, don't worry! You can always change the settings later:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
$ php bin/console make:entity
Class name of the entity to create or update (e.g. BraveChef):
> Product
New property name (press <return> to stop adding fields):
> category
Field type (enter ? to see all types) [string]:
> relation
What class should this entity be related to?:
> Category
Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
> ManyToOne
Is the Product.category property allowed to be null (nullable)? (yes/no) [yes]:
> no
Do you want to add a new property to Category so that you can access/update
Product objects from it - e.g. $category->getProducts()? (yes/no) [yes]:
> yes
New field name inside Category [products]:
> products
Do you want to automatically delete orphaned App\Entity\Product objects
(orphanRemoval)? (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
This made changes to two entities. First, it added a new category
property to
the Product
entity (and getter & setter methods):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// src/Entity/Product.php
namespace App\Entity;
// ...
class Product
{
// ...
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
private Category $category;
public function getCategory(): ?Category
{
return $this->category;
}
public function setCategory(?Category $category): self
{
$this->category = $category;
return $this;
}
}
1 2 3 4 5 6 7 8 9 10
# src/Resources/config/doctrine/Product.orm.yml
App\Entity\Product:
type: entity
# ...
manyToOne:
category:
targetEntity: App\Entity\Category
inversedBy: products
joinColumn:
nullable: false
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<!-- src/Resources/config/doctrine/Product.orm.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://doctrine-project.org/schemas/orm/doctrine-mapping
https://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="App\Entity\Product">
<!-- ... -->
<many-to-one
field="category"
target-entity="App\Entity\Category"
inversed-by="products">
<join-column nullable="false"/>
</many-to-one>
</entity>
</doctrine-mapping>
This ManyToOne
mapping is required. It tells Doctrine to use the category_id
column on the product
table to relate each record in that table with
a record in the category
table.
Next, since one Category
object will relate to many Product
objects,
the make:entity
command also added a products
property to the Category
class that will hold these objects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// src/Entity/Category.php
namespace App\Entity;
// ...
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class Category
{
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category')]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
// addProduct() and removeProduct() were also added
}
1 2 3 4 5 6 7 8 9 10
# src/Resources/config/doctrine/Category.orm.yml
App\Entity\Category:
type: entity
# ...
oneToMany:
products:
targetEntity: App\Entity\Product
mappedBy: category
# Don't forget to initialize the collection in
# the __construct() method of the entity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
<!-- src/Resources/config/doctrine/Category.orm.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://doctrine-project.org/schemas/orm/doctrine-mapping
https://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="App\Entity\Category">
<!-- ... -->
<one-to-many
field="products"
target-entity="App\Entity\Product"
mapped-by="category"/>
<!--
don't forget to init the collection in
the __construct() method of the entity
-->
</entity>
</doctrine-mapping>
The ManyToOne
mapping shown earlier is required, But, this OneToMany
is optional: only add it if you want to be able to access the products that are
related to a category (this is one of the questions make:entity
asks you). In
this example, it will be useful to be able to call $category->getProducts()
.
If you don't want it, then you also don't need the inversedBy
or mappedBy
config.
What is the ArrayCollection Stuff?
The code inside __construct()
is important: The $products
property must
be a collection object that implements Doctrine's Collection
interface.
In this case, an ArrayCollection object is used. This looks and acts almost
exactly like an array, but has some added flexibility. Just imagine that
it is an array
and you'll be in good shape.
Your database is set up! Now, run the migrations like normal:
1 2
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Thanks to the relationship, this creates a category_id
foreign key column on
the product
table. Doctrine is ready to persist our relationship!
Saving Related Entities
Now you can see this new code in action! Imagine you're inside a controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// src/Controller/ProductController.php
namespace App\Controller;
// ...
use App\Entity\Category;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product', name: 'product')]
public function index(EntityManagerInterface $entityManager): Response
{
$category = new Category();
$category->setName('Computer Peripherals');
$product = new Product();
$product->setName('Keyboard');
$product->setPrice(19.99);
$product->setDescription('Ergonomic and stylish!');
// relates this product to the category
$product->setCategory($category);
$entityManager->persist($category);
$entityManager->persist($product);
$entityManager->flush();
return new Response(
'Saved new product with id: '.$product->getId()
.' and new category with id: '.$category->getId()
);
}
}
When you go to /product
, a single row is added to both the category
and
product
tables. The product.category_id
column for the new product is set
to whatever the id
is of the new category. Doctrine manages the persistence of this
relationship for you:
If you're new to an ORM, this is the hardest concept: you need to stop thinking
about your database, and instead only think about your objects. Instead of setting
the category's integer id onto Product
, you set the entire Category
object.
Doctrine takes care of the rest when saving.
Updating the Relationship from the Inverse Side
Could you also call $category->addProduct()
to change the relationship? Yes,
but, only because the make:entity
command helped us. For more details,
see: associations-inverse-side.
Fetching Related Objects
When you need to fetch associated objects, your workflow looks like it did
before. First, fetch a $product
object and then access its related
Category
object:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Controller/ProductController.php
namespace App\Controller;
use App\Entity\Product;
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->find($id);
// ...
$categoryName = $product->getCategory()->getName();
// ...
}
}
In this example, you first query for a Product
object based on the product's
id
. This issues a query to fetch only the product data and hydrates the
$product
. Later, when you call $product->getCategory()->getName()
,
Doctrine silently makes a second query to find the Category
that's related
to this Product
. It prepares the $category
object and returns it to
you.
What's important is the fact that you have access to the product's related category, but the category data isn't actually retrieved until you ask for the category (i.e. it's "lazily loaded").
Because we mapped the optional OneToMany
side, you can also query in the other
direction:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function showProducts(CategoryRepository $categoryRepository, int $id): Response
{
$category = $categoryRepository->find($id);
$products = $category->getProducts();
// ...
}
}
In this case, the same things occur: you first query for a single Category
object. Then, only when (and if) you access the products, Doctrine makes a second
query to retrieve the related Product
objects. This extra query can be avoided
by adding JOINs.
Relationships and Proxy Classes
This "lazy loading" is possible because, when necessary, Doctrine returns a "proxy" object in place of the true object. Look again at the above example:
1 2 3 4 5 6 7
$product = $productRepository->find($id);
$category = $product->getCategory();
// prints "Proxies\AppEntityCategoryProxy"
dump(get_class($category));
die();
This proxy object extends the true Category
object, and looks and
acts exactly like it. The difference is that, by using a proxy object,
Doctrine can delay querying for the real Category
data until you
actually need that data (e.g. until you call $category->getName()
).
The proxy classes are generated by Doctrine and stored in the cache directory.
You'll probably never even notice that your $category
object is actually
a proxy object.
In the next section, when you retrieve the product and category data
all at once (via a join), Doctrine will return the true Category
object, since nothing needs to be lazily loaded.
Joining Related Records
In the examples above, two queries were made - one for the original object
(e.g. a Category
) and one for the related object(s) (e.g. the Product
objects).
Tip
Remember that you can see all of the queries made during a request via the web debug toolbar.
If you know up front that you'll need to access both objects, you
can avoid the second query by issuing a join in the original query. Add the
following method to the ProductRepository
class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Repository/ProductRepository.php
// ...
class ProductRepository extends ServiceEntityRepository
{
public function findOneByIdJoinedToCategory(int $productId): ?Product
{
$entityManager = $this->getEntityManager();
$query = $entityManager->createQuery(
'SELECT p, c
FROM App\Entity\Product p
INNER JOIN p.category c
WHERE p.id = :id'
)->setParameter('id', $productId);
return $query->getOneOrNullResult();
}
}
This will still return an array of Product
objects. But now, when you call
$product->getCategory()
and use that data, no second query is made.
Now, you can use this method in your controller to query for a Product
object and its related Category
in one query:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->findOneByIdJoinedToCategory($id);
$category = $product->getCategory();
// ...
}
}
Setting Information from the Inverse Side
So far, you've updated the relationship by calling $product->setCategory($category)
.
This is no accident! Each relationship has two sides: in this example, Product.category
is the owning side and Category.products
is the inverse side.
To update a relationship in the database, you must set the relationship on the
owning side. The owning side is always where the ManyToOne
mapping is set
(for a ManyToMany
relation, you can choose which side is the owning side).
Does this mean it's not possible to call $category->addProduct()
or
$category->removeProduct()
to update the database? Actually, it is possible,
thanks to some clever code that the make:entity
command generated:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/Entity/Category.php
// ...
class Category
{
// ...
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setCategory($this);
}
return $this;
}
}
The key is $product->setCategory($this)
, which sets the owning side. Thanks,
to this, when you save, the relationship will update in the database.
What about removing a Product
from a Category
? The make:entity
command
also generated a removeProduct()
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// src/Entity/Category.php
namespace App\Entity;
// ...
class Category
{
// ...
public function removeProduct(Product $product): self
{
if ($this->products->contains($product)) {
$this->products->removeElement($product);
// set the owning side to null (unless already changed)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
Thanks to this, if you call $category->removeProduct($product)
, the category_id
on that Product
will be set to null
in the database.
Warning
Please be aware that the inverse side could be associated with a large amount of records.
I.e. there could be a large amount of products with the same category.
In this case $this->products->contains($product)
could lead to unwanted database
requests and very high memory consumption with the risk of hard to debug "Out of memory" errors.
So make sure if you need an inverse side and check if the generated code could lead to such issues.
But, instead of setting the category_id
to null, what if you want the Product
to be deleted if it becomes "orphaned" (i.e. without a Category
)? To choose
that behavior, use the orphanRemoval option inside Category
:
1 2 3 4 5 6
// src/Entity/Category.php
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
private array $products;
Thanks to this, if the Product
is removed from the Category
, it will be
removed from the database entirely.
More Information on Associations
This section has been an introduction to one common type of entity relationship, the one-to-many relationship. For more advanced details and examples of how to use other types of relations (e.g. one-to-one, many-to-many), see Doctrine's Association Mapping Documentation.
Note
If you're using attributes, you'll need to prepend all attributes with
#[ORM\]
(e.g. #[ORM\OneToMany]
), which is not reflected in Doctrine's
documentation.