Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Hydrators

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:

grep -rl "^use App\\\\Core\\\\Utils\\\\Hydrators\\\\OrmEntityHydrator" app/UI \
  --include="*.php" | sed 's|app/UI/\([^/]*\)/.*|\1|' | sort | uniq -c

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#

  1. The column goes into the shared ancestor in app/Core/Base/Shared/<Module>/Models/Entities/ — a protected typed property with #[ORM\Column(...)], a getter and its setter right after it.
  2. Nothing else is needed for a scalar: both sides take the type from the type hint.
  3. 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#

  1. In the Api entity, the ORM mapping (#[ORM\ManyToOne], #[ORM\OneToMany]) — this is the source of truth about the relation.
  2. 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;
  1. Name the accessor exactly after the property — $translations → getTranslations().
  2. Write cross-module ManyToOne without inversedBy — OrmEntityHydrator treats such a relation as a back-reference and drops it on write.
  3. Add it to include() wherever you need to read it; it will not load by itself.
  4. 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
  1. Without a date in the identity, choose a surrogate key. The date property is then \DateTime, not \DateTimeImmutable — the DBAL datetime type does not accept an immutable object (that needs datetime_immutable).
  2. If the date has to be in the composite key, map it as type: 'string' in the Y-m-d H:i:s format (Y-m-d for DATE). The physical schema does not change, MySQL accepts the string.
  3. This also applies to a read-only entity over a view (ProductSetCurrentPrice): even find() 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
}
  1. 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
  1. 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:

  1. Look for API_FAILSOFT in the log. When the Api returned a failure envelope (success => false), the hydrator refuses to declare it data and returns null / [] — logging it together with whoever asked for the data. The page degrades, it does not crash.
  2. Check the entry point. A payload with a list belongs to hydrateAll(), a single entity with an envelope to hydrateWithReferences(). hydrate() over an envelope returns nonsense.
  3. Check the property has its hydration attribute. An empty collection on a property without #[CollectionOf] is exactly this case.
  4. Check the accessor name against the property name.
  5. Check the association is in include(). Without it the Api does not put it in the payload.
  6. 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

Cookie settings