OneToMany in Symfony: the Doctrine pitfalls we all run into
An order and its items, a category and its products, a post and its comments: OneToMany relations are everywhere. make:entity generates one in a few questions, and the Doctrine relations concept fits in a paragraph.
And yet it is one of the most frequent sources of bugs I come across in the Symfony projects I audit. An item that doesn't get saved, an item that vanishes, a page that sends two hundred queries, an order that shows one item out of three. None of these bugs throws a clear error in the right place.
This article walks through these pitfalls on a single example, an order and its items. Every behaviour described here was checked against Doctrine ORM 3.7, with the SQL queries to prove it. You can see it for yourself: at the end of each section, a link opens the example in the Symfony playground, in your browser and without an account, with the queries sent and the fix to try.
The starting point
#[ORM\Entity] class Order { /** @var Collection<int, OrderItem> */ #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order')] private Collection $items; public function __construct() { $this->items = new ArrayCollection(); } } #[ORM\Entity] class OrderItem { #[ORM\ManyToOne(inversedBy: 'items')] #[ORM\JoinColumn(nullable: false)] private ?Order $order = null; public function __construct( #[ORM\Column] private string $product, #[ORM\Column] private int $quantity = 1, #[ORM\Column] private int $position = 0, #[ORM\Column] private bool $free = false, ) { } }
(In a real project, order is a reserved SQL word: give the entity a #[ORM\Table(name: 'orders')].)
In the database there is only one column: order_id, in the items table. A OneToMany relation does not exist in SQL. It is a PHP-side abstraction, and almost every pitfall below comes from that.
Pitfall #1: writing to the side that doesn't count
Doctrine distinguishes two sides. The owning side is the ManyToOne, OrderItem::$order, the one that holds the order_id column. The inverse side is the OneToMany, Order::$items, declared with mappedBy. To decide the value of order_id, Doctrine only looks at the owning side.
So adding an item to the collection alone achieves nothing useful:
$order->getItems()->add($item); $entityManager->persist($item); $entityManager->flush(); // NOT NULL constraint failed: order_item.order_id
Doctrine tries to insert the item with an empty order_id. With nullable: false, the database refuses; without it, the item is saved as an orphan, with no error at all, which is worse.
Removal has the same flaw. Removing an item from the collection with removeElement(), with no other configuration, sends no query at all: the item stays attached to its order in the database.
The reverse is misleading too. If you only set the owning side, the database is right, but the object in memory isn't always:
$item->setOrder($order); $entityManager->persist($item); $entityManager->flush(); // 0 if the collection was already loaded: Doctrine doesn't update it. count($order->getItems());
An order total computed right after from getItems() will be wrong.
The fix: never manipulate the collection from outside; go through methods that keep both sides in sync. That's what make:entity generates:
public function addItem(OrderItem $item): static { if (!$this->items->contains($item)) { $this->items->add($item); $item->setOrder($this); } return $this; } public function removeItem(OrderItem $item): static { if ($this->items->removeElement($item)) { if ($item->getOrder() === $this) { $item->setOrder(null); } } return $this; }
Watch out for the generated removeItem(): it sets the item's order back to null. With nullable: false, and without the configuration from pitfall #3, flush() attempts an UPDATE ... SET order_id = NULL that the database rejects. It's a useful signal: an order item without an order makes no sense, so you have to decide to delete it.
The getter, for its part, returns the mutable collection. You can type its return as ReadableCollection: the object is the same at runtime, but PHPStan and your IDE will flag a getItems()->add(). Avoid returning toArray(), though: it loads every item on each call and loses the EXTRA_LAZY optimisation from pitfall #5.
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Pitfall #2: cascade, neither everywhere nor nowhere
With addItem(), both sides are in sync. But if you only persist the order:
$order->addItem(new OrderItem('Coffee', 2)); $entityManager->persist($order); $entityManager->flush();
Doctrine refuses:
A new entity was found through the relationship 'Order#items' that was not configured to cascade persist operations for entity: OrderItem@234.
It is actually by walking the inverse side that Doctrine spots this unknown item: it doesn't use it to write order_id, but it reads it to find new entities.
The message suggests two fixes: call persist() on the item, or add cascade: ['persist'] to the relation. Both are correct, and the choice is not a matter of taste.
Cascading makes sense when the child cannot exist without its parent: an order item, a shipping address, an answer to a questionnaire. This is called an aggregate: you save the order, and it carries its items along.
It doesn't when the child has a life of its own. A category and its products, for instance: you don't create a product by saving a category. Adding cascade: ['persist'] everywhere "to make the error go away" removes a useful safeguard, the one that tells you a new entity slipped in where you didn't expect it.
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Pitfall #3: three ways to delete, three behaviours
Deleting an order that has items, with nothing configured, fails on the foreign key (on SQLite, only if foreign keys are enabled with PRAGMA foreign_keys = ON). There are three mechanisms to deal with this, and they are often confused.
cascade: ['remove'], on the OneToMany, is handled by Doctrine. Before deleting the order, it loads the items and deletes them one by one. For an order with three items:
SELECT ... FROM order_item t0 WHERE t0.order_id = ? DELETE FROM order_item WHERE id = ? DELETE FROM order_item WHERE id = ? DELETE FROM order_item WHERE id = ? DELETE FROM orders WHERE id = ?
It's slow on large volumes, but Doctrine listeners (preRemove, postRemove) are called for every item.
onDelete: 'CASCADE', on the owning side's JoinColumn, is handled by the database. On its own, it makes Doctrine send a single query:
DELETE FROM orders WHERE id = ?
The database deletes the items. It's fast, but Doctrine knows nothing about it: no listener is called, and OrderItem objects already loaded stay in memory, managed by the EntityManager, although they no longer exist in the database. Its real value: deletions that don't go through remove(), such as a DQL or SQL DELETE, which Doctrine does not cascade.
orphanRemoval: true, on the OneToMany, answers a different question: what happens to an item you remove from the collection? Without this option, nothing, or an error with the generated removeItem(). With it, Doctrine deletes it on flush():
$order->removeItem($item); $entityManager->flush(); // DELETE FROM order_item WHERE id = ?
This is often what you want for an aggregate: an item removed from the order has no reason to exist anymore. And orphanRemoval also implies cascading deletion when the order itself is deleted, with the same queries as cascade: ['remove'], even if onDelete: 'CASCADE' is also set.
My rule: cascade: ['persist'] and orphanRemoval: true for an aggregate; add onDelete: 'CASCADE' if you also delete orders in bulk with a DQL or SQL query.
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Pitfall #4: replacing the collection
This one is the most costly, because it destroys data. It shows up with a setter, often added for a form or an API:
public function setItems(array $items): void { $this->items = new ArrayCollection($items); }
Take an order with three items, with cascade: ['persist'] and orphanRemoval: true. We pass it its three existing items, plus a new one, already attached to the order:
$newItem = new OrderItem('Juice'); $newItem->setOrder($order); $order->setItems([...$order->getItems(), $newItem]); $entityManager->flush();
Expected result: four items. The queries sent:
DELETE FROM order_item WHERE order_id = ? INSERT INTO order_item (product, quantity, position, free, order_id) VALUES (?, ?, ?, ?, ?)
One item is left. Seeing the original collection replaced by another object, Doctrine considers it emptied: with orphanRemoval, it deletes all its items at once, including those that are also in the new collection, then inserts the only new item. Without orphanRemoval, the replacement deletes nothing, but it removes nothing either: the setter doesn't do what its name promises.
The fix: no collection setter. The collection is created once, in the constructor, then modified in place with addItem() and removeItem(). Symfony's tools know how to use them: the Serializer and the PropertyAccessor use the adder and remover when they exist, and a form CollectionType does so with the by_reference: false option.
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Pitfall #5: N+1 queries, and half-loaded collections
A OneToMany collection is loaded lazily: the query runs on the first iteration. Displaying a list of five orders with their items therefore sends six queries, one for the list and one per order. With fifty orders, fifty-one. This is the N+1 problem.
The well-known fix is a join. But a join alone isn't enough:
// Still six queries: the join filters, it doesn't hydrate the items. $this->createQueryBuilder('o') ->leftJoin('o.items', 'i') ->getQuery()->getResult(); // A single query: addSelect() tells Doctrine to fill the collections. $this->createQueryBuilder('o') ->leftJoin('o.items', 'i') ->addSelect('i') ->getQuery()->getResult();
(leftJoin() rather than join(): a join() would drop orders that have no items.)
This join with addSelect() has two pitfalls of its own.
Filtering on the join gives incomplete collections. We want the orders that contain at least one free item:
$this->createQueryBuilder('o') ->join('o.items', 'i') ->addSelect('i') ->where('i.free = true') ->getQuery()->getResult();
The orders are the right ones, but each one now has a single item in getItems(): the free one. Doctrine filled the collection with what the query returned, and now considers it complete. Worse, it stays that way for the whole life of the EntityManager, until a clear(): a later find() or findOneBy() on the same order returns the object already in memory, with its truncated collection. In a console command or a Messenger worker, that can last a long time. I've seen this bug in an invoice that showed only some of its items, depending on the code that ran just before in the same process.
The fix: take the filter out of the hydrating join, for example with an EXISTS subquery. It's still a single query, and the collections are complete:
$this->createQueryBuilder('o') ->leftJoin('o.items', 'i') ->addSelect('i') ->where('EXISTS (SELECT i2.id FROM App\Entity\OrderItem i2 WHERE i2.order = o AND i2.free = true)') ->getQuery()->getResult();
Limiting the number of results cuts collections. setMaxResults(2) on a query with a join limits the number of SQL rows, not the number of orders. You get one order with two items instead of two complete orders. To paginate a query with a join, you need Doctrine's paginator, Doctrine\ORM\Tools\Pagination\Paginator: it first fetches the IDs of the orders on the page, then the complete orders, in two queries (plus one for the total).
Finally, if a page only needs the number of items, fetch: 'EXTRA_LAZY' on the relation avoids loading them: as long as the collection isn't loaded, count($order->getItems()) sends a SELECT COUNT(*) instead of fetching every item. contains() and slice() benefit too.
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Pitfall #6: #[ORM\OrderBy] only sorts on load
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order')] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $items;
The attribute adds an ORDER BY to the query that loads the collection. That's all. An item added with addItem() goes to the end, whatever its position, and stays there until the next load:
$order->addItem(new OrderItem('Bread', 1, position: 0)); $entityManager->flush(); // In memory: Coffee, Tea, Juice, Bread // Reloaded: Bread, Coffee, Tea, Juice
If the order matters right after adding, for example to return the order in the API response, you have to sort it yourself:
use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Order; $this->items->matching(Criteria::create()->orderBy(['position' => Order::Ascending]));
▶ Reproduce this pitfall in the Symfony playground, SQL queries included.
Summary
| Situation | What happens | The fix |
|---|---|---|
getItems()->add() alone |
Empty foreign key, or orphan item | addItem() that updates both sides |
| New child, persisted parent | "A new entity was found…" | Explicit persist(), or cascade: ['persist'] for an aggregate |
| Item removed from the collection | Nothing, or a NOT NULL error |
orphanRemoval: true for an aggregate |
| Parent deleted | Foreign key error | orphanRemoval or cascade: ['remove'] (Doctrine), onDelete: 'CASCADE' (database) |
| Collection replaced | With orphanRemoval, all previous items deleted |
No setter, an adder and a remover |
| Loop over collections | N+1 queries | leftJoin() + addSelect() |
| Filter on the join | Incomplete collections, kept in memory | Filter with EXISTS |
| Pagination with a join | Cut collections | Doctrine's Paginator |
One last tip: run php bin/console doctrine:schema:validate. It doesn't catch these behavioural pitfalls, but it flags a mappedBy or inversedBy that don't match, and that's often where pitfall #1 starts.