Hydrators#
How an API response becomes an entity, and an entity becomes data to store. The system has two hydrators and they are not interchangeable — each belongs to a different layer.
| Class | Where it runs | What it builds on | What it can do |
|---|---|---|---|
OrmEntityHydrator |
the Api layer | Doctrine metadata + EntityManager |
hydrate() (reading from the DB) and hydrateForPersist() (building a graph to save) |
EntityHydrator |
Front and Admin | PHP reflection + hydration attributes only | hydrate(), hydrateAll(), hydrateWithReferences(), extract(), applyIdentity() |
Both live in app/Core/Utils/Hydrators/.
The split holds in the code without exception
OrmEntityHydrator is imported by 144 files, all under app/UI/Api/.
EntityHydrator is imported by 135 files under app/UI/Admin/ and 109 under
app/UI/Front/ — and by none under Api. Recompute it like this:
Front and Admin must not touch OrmEntityHydrator
Through EntityManager it would open the database from a layer that is not meant
to reach it (Five-layer model). It would work — locally and on
the dev server, because the database is right there. It only falls apart where
Front runs somewhere else than Api, and by then the shortcut has spread.
The whole path from query to entity#
Front/Admin presenter
│ $manager->find($id)->include([…])->execute()
▼
Front/Admin Manager → Service → Mapper ────► Repository ─► ApiBridge
│ │
│ ▼
│ Api presenter
│ │
│ Api Manager → … → Repository
│ │ Doctrine + OrmEntityHydrator
│ ▼
│ extractAll() → wire payload
◄────────────────────────────┘
EntityHydrator::hydrate() / hydrateAll()
│
▼
Front/Admin entity
What matters is where the boundary is: an Api entity and a Front entity are two
different classes over the same table. News exists in four shapes —
app/Core/Base/Shared/Blog/… (the abstract ancestor) plus a variant under Api/,
Admin/ and Front/. What flows between them is not an object but an array.
The shape of the data on the wire#
{
"items": [
{
"id": 12,
"published": "2026-08-15 10:00:00",
"author": { "id": 3 },
"translations": [ { "id": 40, "langId": 1, "title": "…" } ]
}
],
"totalCount": 137,
"_references": {
"App\\UI\\Api\\System\\Models\\Entities\\User": { "3": { "id": 3, "name": "…" } }
}
}
| Key | Meaning |
|---|---|
items / data |
a list / a single entity |
totalCount |
the total count for pagination (only when asked for) |
_references |
shared entities pulled aside so they are not repeated in every item |
{"id": 12} |
a reference — the hydrator resolves it from _references, not from the database |
hydrateAll() and hydrateWithReferences() first load _references into an internal
cache and only then hydrate the items. Api classes from _references are rewritten to
the target module (\Api\ → \Front\ or \Admin\) according to the entity you are
hydrating.
hydrate() on its own does not read _references
Call hydrate() directly on a payload containing a reference and the cache is
empty, so the association stays null. Nothing crashes — the author simply does
not show up on the page and there is no error anywhere. Pick the entry point by the
shape of the payload: a list → hydrateAll(), a single entity with an envelope →
hydrateWithReferences().
How EntityHydrator figures out types#
It has no Doctrine metadata, so it derives the property type from PHP reflection. Where reflection cannot carry it, a hydration attribute supplies it:
| Attribute | When it is needed | What happens without it |
|---|---|---|
#[CollectionOf(Xxx::class, owned: true)] |
always on Collection / array |
the item type cannot be determined, the collection stays empty |
#[EntityOf(Xxx::class)] |
the property is untyped or typed to a shared base class | the reference is looked up under the wrong class and is not found |
#[Identity] |
composite or derived identity (the *Text family) |
fallback to the id property — enough for a single-PK entity, so the attribute is not needed |
#[ParentRef] |
a child's back-reference to its parent | extract() would loop / send redundant data |
#[Transient] |
a computed property with no DB column | the hydrator picks it up by reflection and sends it to the Api |
owned: true on a collection marks an owned collection (Doctrine
cascade: ['persist'] + orphanRemoval, typically translations): extract()
serialises it with the children's full data, so the Api write can insert and update
them. owned: false (the default, typically M2M) sends only {"id": …} and the child
is resolved from _references when reading.
#[Identity] and #[ParentRef] on the same property behave differently
With a derived identity (a *Text whose PK is formed by the link to its parent)
the link must end up in the payload, otherwise the child loses its own identity.
The hydrator handles this: when the property is also #[Identity], the ParentRef
skip does not apply.
Procedure: adding a column to an entity#
- The column goes into the shared ancestor in
app/Core/Base/Shared/<Module>/Models/Entities/— aprotectedtyped property with#[ORM\Column(...)], a getter and its setter right after it. - Nothing else is needed for a scalar: both sides take the type from the type hint.
- Click through reading and writing. Reading: the value shows up on the front end. Writing: save it from the administration and check it really landed in the database.
A scalar foreign key next to an association = two truths about one thing
When an entity has both $userId (a column) and $user (an association), write
only one of them. Writing both means the one Doctrine processes later wins — and
which one that is depends on the key order in the payload, not on your code.
Procedure: adding an association or a collection#
- In the Api entity, the ORM mapping (
#[ORM\ManyToOne],#[ORM\OneToMany]) — this is the source of truth about the relation. - In the Front and Admin variants, the same property plus a hydration attribute:
/** @var Collection<int, NewsText> */
#[ORM\OneToMany(targetEntity: NewsText::class, mappedBy: 'parent',
cascade: ['persist', 'remove'], orphanRemoval: true, fetch: 'LAZY')]
#[CollectionOf(NewsText::class, owned: true)]
protected Collection $translations;
- Name the accessor exactly after the property —
$translations→getTranslations(). - Write cross-module
ManyToOnewithoutinversedBy—OrmEntityHydratortreats such a relation as a back-reference and drops it on write. - Add it to
include()wherever you need to read it; it will not load by itself. - Verify both directions — loading (the collection has items) and saving (the items survive the save).
An accessor named differently from the property does not crash, it just goes quiet
EntityHydrator looks the setter up by the property name. On a mismatch the
collection is not filled, the Api does not serialise it, and the admin form saves it
empty. It surfaces as "the translations disappeared" a few days later, not as a
hydration error.
Procedure: saving an entity from the administration#
The path is always the same and can be read in
app/Core/Traits/Shared/Models/Mappers/SaveTrait.php:
$data = $this->hydrator->extract($entity); // 1. entity → flat array
$result = $this->repository->save($data, $meta); // 2. via the bridge to the Api endpoint
if ($result->isSuccess()) {
$this->hydrator->applyIdentity($entity, $result->getData()); // 3. the id back
}
extract()walks the properties by reflection: owned collections inline with full data, other associations as{"id": …},#[ParentRef]and#[Transient]skipped.- The
…/saveApi endpoint first strips the fields the caller is not allowed to write (#[ExternalReadonly],#[ExternalAdminOnly]— see Column write attributes) and only then callshydrateForPersist(). hydrateForPersist()builds a managed object graph so thatflush()resolves insert, update and delete on its own:
| Situation | Behaviour |
|---|---|
an entity with an id |
the managed one is found and hydrated into (update) |
without an id |
new (insert) |
| an owned collection | merged into the parent's collection, missing items are removed |
| a child's back-reference | set from the parent, not from the data |
| ManyToMany | the whole collection is replaced |
applyIdentity()writes the generated id back into your entity, so you can build on it right away.
Saving an object graph REPLACES, it does not append
A collection item missing from the payload is deleted. When a form loads only
part of a collection (say the translations of one language) and then saves the
graph, the rest disappears. That is why includes must carry the whole
collection, not a selection from it.
A filtered include poisons the whole collection
Loading a collection with a filter looks like a saved query. On the following save, however, everything the filter cut off counts as "should no longer be there" and is deleted. Filter over the loaded collection in PHP, not while loading it for processing.
Removing and re-adding the same item = DELETE + INSERT
The new row gets a different id. Wherever the old id is referenced (an invoice, a
document, an external system), the reference breaks — and a reference to a
non-existent row does not show up as an error, only as emptiness.
Procedure: hydration returns null or nothing#
Cheapest first:
- Look for
API_FAILSOFTin the log. When the Api returned a failure envelope (success => false), the hydrator refuses to declare it data and returnsnull/[]— logging it together with whoever asked for the data. The page degrades, it does not crash. - Check the entry point. A payload with a list belongs to
hydrateAll(), a single entity with an envelope tohydrateWithReferences().hydrate()over an envelope returns nonsense. - Check the property has its hydration attribute. An empty collection on a
property without
#[CollectionOf]is exactly this case. - Check the accessor name against the property name.
- Check the association is in
include(). Without it the Api does not put it in the payload. - Only then reach for
HydratorDebugger(app/Core/Utils/Debug/HydratorDebugger.php) — it prints what the hydrator did with which key.
Empty entity data throws, a failed response does not
hydrate() over data holding only nulls and an empty id throws a
RuntimeException. A failed Api response, by contrast, passes silently as null.
These are two different states and each is found somewhere else.
The identity map#
The write path has a landmine of its own that is related to hydration but stands apart:
the Api repository clears the Doctrine identity map at the start of every read. It
has its own chapter — Identity map and clearDoctrine(). Read it
before you start writing lifecycle hooks.
Where to look#
| I want | Where |
|---|---|
| the Api layer hydrator | app/Core/Utils/Hydrators/OrmEntityHydrator.php |
| the Front/Admin hydrator | app/Core/Utils/Hydrators/EntityHydrator.php |
| the hydration attributes | app/Core/Attributes/Hydration/ |
| the generic mapper save | app/Core/Traits/Shared/Models/Mappers/SaveTrait.php |
| the permission stripping on write | app/Core/Traits/Api/Presenters/SaveTrait.php |
| hydration diagnostics | app/Core/Utils/Debug/HydratorDebugger.php |
Follow-up chapters: Identity map and clearDoctrine() ·
Column write attributes · Calling the API ·
Five-layer model