The identity map and clearDoctrine()#
The most serious landmine in the whole system. Anyone who does not know about it hits it within a week — and spends a day looking for the bug in a place where it is not.
The mechanism#
The Api repository empties the Doctrine identity map at the start of every read:
// app/Core/Traits/Api/Models/Repositories/GetAllTrait.php
public function getAll(…): array
{
$this->clearDoctrine(); // ← the VERY FIRST line
…
}
protected function clearDoctrine(): void
{
$this->db->getUnitOfWork()->clear(); // the whole identity map is gone
$cache = $this->db->getConfiguration()->getResultCache();
if ($cache) { $cache->clear(); }
}
getAll() is the single bottleneck of the entire read stack. get() and getBy()
merely delegate to it (GetTrait and GetByTrait call getAll() with a limit of 1), so
find(), findBy() and findAll() all empty the map in exactly the same way.
It applies to foreign managers and to Admin and Front alike
Both Admin and Front go through the bridge into the same Api stack. A manager calling
another manager for a code list or a setting triggers clearDoctrine() just the same.
"I am only reading one id" is not an innocent operation.
The objects live on, they just stop being managed
That is exactly why it is invisible. Code after clearDoctrine() reads and writes
getters without an error and only breaks at the next flush() — often several layers
away.
Three symptoms#
1. A detached reference#
Doctrine\ORM\ORMInvalidArgumentException:
A new entity was found through the relationship 'X#owner' that was not
configured to cascade persist operations
A hook called a foreign findAll() and detached the reference. At flush() it looks like
a new entity without a cascade.
The fix: reattach through $em->getReference() after all the foreign calls, or
replace the foreign call with raw DBAL.
2. A lazy collection initialised after clearDoctrine()#
Nastier, because "do not call find() in hooks" is not enough:
- A service loads an entity with
includes— a collection not among them stays lazy. - Along the way a foreign manager is called →
clearDoctrine()→ the owner is detached. - Only now the lazy collection is iterated → the children are managed but their back reference points at a detached owner.
flush()→ the same message.
includes must cover every collection touched by anything invoked from it
Not just what the orchestrating service reads itself. Where that is impossible, hydrate the collections with an explicit "touch" while the owner is still managed.
3. A duplicate instance#
While adding an entity of class …\OrderShipper with an ID hash of "1" to the
identity map, another object of class …\OrderShipper was already present for
the same ID.
The graph was loaded, the map was emptied, and the same entity got into it a second time by another route.
Procedure: writing a hook that needs foreign data#
- Load everything BEFORE you start assembling the graph. Code lists, settings, foreign keys — all at the start, not in the middle.
- Do not call
find()orfindAll()inside a hook — not even on your own manager. - Use a safe substitute:
| What I need | The substitute |
|---|---|
| one code list row | a raw DBAL query through $em->getConnection() |
| a setting | SettingContext (in-memory, does not touch the DB) |
| a foreign key for an association | $em->getReference() |
| an existence check | SELECT COUNT through DBAL |
- When a foreign call is unavoidable, do it first and only then load the entity you are going to save.
- Verify by saving, not by reading. The bug only surfaces at
flush().
Raw DBAL in a hook is not a layer violation, it is a necessity
That is why InvoiceNumberGenerator does it: it reads the series and MAX(order)
purely through getConnection() so as not to touch the identity map in the middle of
an invoice's half-built graph.
Procedure: I got "A new entity was found through the relationship"#
Cheapest first:
- Do not look at where
flush()failed. The exception arises at save time; the culprit is a read that happened earlier — sometimes several layers up. - Walk the path from loading the entity to
flush()and look for every call tofind()/findBy()/findAll(), foreign managers included. - Look at the
includes. Is a collection missing that somebody iterates later on? - Check the manager's lifecycle hooks — and the hooks of the managers you call from them.
- Measure it, do not guess: Doctrine offers an
onClearevent. Register a listener printing the call stack and you will see exactly which call emptied the map. - Only then reach for
getReference()as a patch — first you have to know what cleared the map.
The message points at flush(), the culprit is elsewhere
This is the main reason it takes so long to find. The stack trace shows the save, not the read — and the read can be in an entirely different module.
Procedure: adding a read into an already working process#
Typically "I just need the user's name here as well":
- Find out whether you are inside a half-built graph. If you are in
beforeSave,afterSave, in a generator or in an orchestrating service between the load and the save — you are. - If so, fetch the data with a raw DBAL query, or load it before the process starts and pass it in as a parameter.
- If not (a presenter, a component, a read path), an ordinary
findAll()is fine. - Never rely on "it is only one id" — the size of the query has nothing to do with
it; even a single-row
SELECTempties the map.
The rule#
All the reads first, only then build the graph
The one sentence that sums this landmine up. When you need a code list, a setting or foreign data, load it before you start assembling the object graph to be saved. Not in the middle.
Where to look#
| I want | Where |
|---|---|
| the clearing itself | app/Core/Traits/Api/Models/Repositories/GetAllTrait.php → clearDoctrine() |
why get() and getBy() do it too |
…/Repositories/{Get,GetBy}Trait.php — they delegate to getAll() |
| a finished example of going around it via DBAL | app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php |
| the lifecycle hooks this concerns | app/Core/Traits/Api/Models/Managers/SaveTrait.php |
Follow-up chapters: Manager lifecycle · Hydrators · Calling the API · Five-layer model