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().
hydrateAll() and hydrateWithReferences() return only entities of the main class,
even when _references carries another one too (for WorkItem jobs, for example, the
WorkSheet the front end groups by). Those are hydrated into the cache anyway;
EntityHydrator::getCached(WorkSheet::class) takes them out — with the target Front/Admin
class, not the Api one. The pattern is app/UI/Front/Company/Models/Mappers/Api/WorkItemMapper.php.
Call getCached() right after hydrating the same payload
Both hydration methods start with clearCache(). A call without a preceding hydration
in the same request, or only after hydrating a different response, returns [] or
entities from someone else's payload.
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: designing the primary key of an owned entity#
The hydrator matches a submitted child of an owned collection to an existing one by
identity parts converted to strings (OrmEntityHydrator::findExistingChild(),
idKey()). Whatever does not match is a new row to it — and orphanRemoval deletes the
old one. Therefore:
| Situation | Key | Example |
|---|---|---|
| several rows for the same pair (multi-value parameter) | surrogate *_id |
ProductSetParameterRel |
| a time series in an owned collection saved by a form | surrogate *_id, the date is an ordinary type: 'datetime' column |
ShipperTariffPrice, PaymentMethodPrice |
| the date has to be part of the composite key | the column stays DATETIME/DATE, in the ORM type: 'string' |
ProductSetPrice::$publishedTime, ProductSetFeePrice::$publishedDatetime, ProductSetStatistic::$date |
- Without a date in the identity, choose a surrogate key. The date property is then
\DateTime, not\DateTimeImmutable— the DBALdatetimetype does not accept an immutable object (that needsdatetime_immutable). - If the date has to be in the composite key, map it as
type: 'string'in theY-m-d H:i:sformat (Y-m-dfor DATE). The physical schema does not change, MySQL accepts the string. - This also applies to a read-only entity over a view (
ProductSetCurrentPrice): evenfind()and DQL register the entity in the identity map.
A DateTime in a composite key breaks every persist() and find()
UnitOfWork::getIdHashByIdentifier() runs implode() over the identifier parts and a
DateTime cannot be converted to a string — a fatal error "Object of class DateTime
could not be converted to string".
A composite key with a date in an owned collection silently deletes the history
It is enough for the date format in the payload to differ from the stored one
(seconds, time zone, Y-m-d against Y-m-d H:i:s). The hydrator does not match the
submitted row, inserts it as new and orphanRemoval deletes the whole existing series —
without an error.
A composite identity merges rows with the same key
A multi-value parameter holds several rows per (set, parameter). With the key (set, parameter) the identity map would collapse them into one on read and the other values would disappear.
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.
!!! danger "Do not read a relation id from an extract() payload with (int)"
A to-one relation has the shape ['id' => ['id' => 67]] in the payload (see
tests/Unit/Hydrators/EntityHydratorExtractTest.php). (int) on an array returns 1,
not an error, so (int) ($data['advert']['id'] ?? 0) gives 1 and the write silently
lands on record no. 1. A repository that pulls a relation id out of the payload unwraps
the id key while the value is an array and leaves the conversion to
OwnershipPayload::normalizeId() (pattern AdvertFollowerRepository::referenceId()).
2. The …/save Api endpoint first strips the fields the caller is not allowed to
write (#[ExternalReadonly], #[ExternalAdminOnly] — see
Column write attributes) and the fields nobody may write
(#[FieldAccess] with Audience::None, for example a user's password — see
Procedure: add a new secret field),
and on the external path also the fields outside the caller's write audience (the
insert/update axes), and only then calls hydrateForPersist(). Strings from an
external request are converted by the field type, see
Converting values on write.
3. hydrateForPersist() builds a managed object graph so that flush() 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 form that rebuilds a collection must carry over the id of stored rows
The hydrator pairs children of an owned collection by id. New objects without it are
new rows to it: every save, even without changes, then deletes and reinserts the whole
collection and the rows get new ids. The pattern is
ParameterFieldsTrait::applyProductSetParameterRels() in the e-shop administration —
preferably a row with the same value, otherwise any remaining row with the same key.
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.
Converting values on write#
OrmEntityHydrator receives values of two origins: native types from the internal path
(ApiBridge) and only strings from external HTTP — data travels in the query string, so
?data[currencyId]=1 brings '1'. A typed setter (setCurrencyId(?int)) would fail on a
string with a TypeError.
A string going into a field that is not string is therefore converted by the hydrator
through App\Core\Utils\Hydrators\StringValueCoercer. The target type comes from the setter
parameter, when missing (or mixed) from the property type, and finally from the Doctrine
column mapping. A union type (int|string) is not converted.
| Target | Accepts | Rejects |
|---|---|---|
int |
a canonical integer (-12, 0) |
1.9, 1e2, +1, 01, whitespace, overflow |
float |
a numeric notation (-1.5, .5, 2e3) |
a comma, whitespace, overflow (1e999) |
bool |
1, 0, true, false, on, off (lower case) |
anything else |
DateTime, DateTimeImmutable |
whatever new DateTime() accepts in the application time zone |
an overflowing date (2026-02-31), surrounding whitespace |
an int-backed enum |
a canonical integer, the hydrator turns it into the enum | |
a string-backed enum, string |
the string passes unchanged |
'' on a nullable type means null, on a non-nullable type an error. A value that cannot be
converted throws InvalidFieldValueException with the field name, the expected type and the
(shortened) value. The Api SaveTrait catches it separately: it cleans up the EntityManager
and returns INVALID_DATA (604) with the message Invalid value for field "…": expected …,
got "…". — not ITEM_NOT_FOUND as for other HydrationExceptions.
The conversion opens fields that an error used to protect
As long as strings were not converted, an external write of numeric fields failed — and
thereby accidentally protected a user's credits or discount too. The conversion therefore
came only together with locking fields by write audience
(Column write attributes).
Check a new numeric field of an entity with an owner grant against
OwnerWritableFieldsTest.
A native date from the internal path (DateTime and DateTimeImmutable) is a value, not a
nested entity: the hydrator passes it to the setter and only converts mutable ↔ immutable by
the target type. Before, a DateTimeImmutable fell into nested-entity hydration and the setter
got null.
When the write rejects the payload#
The read path is lenient: an unresolved reference ends as null and is only logged. The
write path (hydrateForPersist()), on the other hand, would rather reject the whole
write, because a silent null would be destructive there — a nulled ToOne relation, an
item dropped from a many-to-many link, or a child of an owned collection deleted through
orphanRemoval.
| Situation in the payload | Exception | Api save result |
|---|---|---|
a reference {"id": X} found neither in the cache nor in the database |
ReferenceNotFoundException |
ITEM_NOT_FOUND (616) |
full data with a concrete id of an entity with a generator (AUTO/IDENTITY/SEQUENCE) that is not in the database |
HydrationException ("refusing to resurrect") |
ITEM_NOT_FOUND (616) |
| a child of an owned collection that belongs to another parent | HydrationException |
ITEM_NOT_FOUND (616) |
| a value that cannot be converted to the field type | InvalidFieldValueException |
INVALID_DATA (604) |
any other hydration error (typically a TypeError in a setter) |
anything else | MISSING_REQUIRED_FIELD (602), exception in the save.hydration log |
The exception is a placeholder identifier 0, '0', null or '': it means a parent
not saved yet (or a new entity) and passes without an error. An entity with a manually
assigned key (no generator, e.g. a derived identity) is inserted normally with an explicit
id.
A deleted record cannot be “saved again” under its old id
The generator would ignore the requested id and insert the row under another one,
and the client would never learn about it. That is why a write with a non-existent id
ends with ITEM_NOT_FOUND; a new record is sent without id (or with 0).
The EntityManager is cleared after an error
Hydration works directly on the identity map, so it may have changed managed entities
before throwing. The Api SaveTrait therefore calls clearEntityManager() in all three
branches; otherwise the next flush() in the same request would write them silently.
Anyone calling hydrateForPersist() outside SaveTrait must do the same.
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 Doctrine identity map is emptied at the boundaries of an operation (one Api call, one Cron action, one script step), not on reads. It has its own chapter — Identity map and operation boundary. 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 operation boundary · Column write attributes · Calling the API · Five-layer model