Note: This article was formerly titled “DON’T despair over partial collections” and has been updated. It’s also less polite now since I’m frustrated with Doctrine ORM (Sept 15, 2026) 😒
Doctrine ORM is such an obstacle to good DDD practice that I sometimes just want to throw it out of the window. In this article I’ll explore a common complaint: Doctrine doesn’t allow you to work with aggregates that include some, but not all, items of a one-to-many association.
Working with partial collections is a rather handy feature to avoid loading a whole sh*t ton of associations, especially when your aggregate doesn’t need them: By definition, a day schedule aggregate doesn’t need to load a full calendar potentially started around the time the dinosaurs where still chewing on leaves, and a wait list doesn’t need to be populated with cancellations and approvals. All perfectly decent requirements but Doctrine says 👎
Non-starter 1: Extra-lazy collection filtering
Doctrine’s intended way of handling these situations is to use an extra-lazy collection (of lazy proxies) of potentially a million entities and to whittle them down inside the aggregate using Criteria::matching() on the extra-lazy collection. Criteria::matching() works at SQL level and hydrates only matching entities leaving the other proxies uninitialised. You could lazy-load the actual aggregate-related collection this way and save it on a not persisted property inside your aggregate (if pulling infrastructure into the domain level doesn’t bother you, that is).
While that sounds great in theory, it fails in practice because — surprise! — collection filtering with Criteria::matching() works with neither custom mapping types nor Scienta JSON functions. You cannot filter on json_document fields likely used to save e.g. TimeSpan value object. Neither can you use an ID value object or state object you might have saved using a custom dbal type. Game over 🫠
Non-starter 2: Explicit states
In those cases requiring filtering according to entity state, we could dump the entire state pattern that is a pain in the behind in php anyway and go full DDD with explicit states (introduced in this book, and here’s an example). I love explicit states since they don’t hide away the business rules!
Doctrine hates them, obviously. That’s because changing an entity’s class, say from AppointmentConfirmed to AppointmentCancelled while keeping its ID confuses the ORM’s identity map. If you think, mmmmh maybe delete and re-insert or insert-update bypassing the ORM, forget about it. I’ve tinkered with this a lot. Don’t go there.
Non-starter 3: Using an on-the-fly aggregate
A very tempting solution: Instead of working with the entire calendar, we could simply use the query builder in our repository to retrieve the desired, filtered entity collection and hydrate an aggregate on the fly. When saving it back, we would again only insert or update the entities in its collection. This would even work with entities removed from the aggregate (like cancellations from a wait list) since Doctrine’s unit of work updates all tracked entities on flush.
That does work well with an active record system and is in fact what Julie Lerman and Steve Smith do in their famous DDD fundamentals course (have a look at their repo). The new schedule they create with getScheduleForDate() is tracked. But our Doctrine isn’t like EFCore. It would never know about the aggregate.
Since you never save the actual aggregate, you lose version control and can’t perform concurrency checks 🫣. Of course, you could go down a rabbit hole comparing all versions of all entities in the collection, querying the entity manager for new insertions, and performing magic on those list removals that you don’t know about anymore… (No, you can’t wave a magic wand to do this, I lied.)
Solution #3: Do it like Eric would
Maybe you remember the good ol’ days when people still listened to music that was “real music”? (Sorry for that comparison, I’ve already used the “dinosaur” metaphor.) That’s the times we’re going back to: Without ORMs it was impossible to auto-hydrate the many-side of relations on an aggregate. Instead you saved a list of their IDs and hydrated back the collection on retrieval manually.
That means you have to persist and retrieve your aggregate in two steps: (1) Retrieve the actual aggregate with its Entity ID list property of associated entities, (2) query the db for all these IDs to retrieve the collection, and (3) hydrate it back on the aggregate through reflection or a populate() method. When updating the aggregate, you would do much the same: (1) Collect all associated entities, (2) insert or update them, and (3) update the aggregate. It’s super old-school, I know. Eric Evans would be proud of you!
Of course, this requires work to keep the identity ID list in sync with the actual entity collection all the time. Both would live inside the aggregate but only the former would eventually be persisted. Hydrating back the collection would also potentially involve quite a few validation checks. It’s still an adequate solution for something like a wait list. Saving day schedules may become an obstacle though if you ever decided to use a weekly schedule as aggregate instead.
Perhaps you’re wondering now: Why use an ORM at all and not go back to plain SQL? The “R” in ORM is for relationships, isn’t it. Well, because of those pesky removed entities. A day schedule aggregate could see appointments rescheduled and a wait list applications cancelled. Those would just disappear from both the entity ID list and the entity collection, and never be updated without any tracking.
Is Doctrine ever going to get better at this, you may ask with an exhausted look on your face . Honestly, I have no idea. Native JSON support might happen one day. “where” conditions in association mapping would likely only work with primitives if implemented like collection filtering. Maybe the real question then is: Is there a better ORM to work with DDD in php? I have only taken a cursory glance at them but it seems like both CycleORM (also included with Spiral framework) and AtlasORM at least support conditional association mapping. The latter seems to have been created with DDD in mind… but hasn’t been updated for a five years. They might be worth a look ☺️