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().

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$translationsgetTranslations().
  2. Write cross-module ManyToOne without inversedByOrmEntityHydrator 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: 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.
  2. The …/save Api endpoint first strips the fields the caller is not allowed to write (#[ExternalReadonly], #[ExternalAdminOnly] — see Column write attributes) and only then calls hydrateForPersist().
  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 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:

  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 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