Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Hydrátory

Hydrátory#

Jak se z odpovědi API stane entita a z entity zase data k uložení. V systému jsou dva hydrátory a nejsou zaměnitelné — každý patří jiné vrstvě.

Třída Kde běží Na čem stojí Co umí
OrmEntityHydrator Api vrstva Doctrine metadata + EntityManager hydrate() (čtení z DB) a hydrateForPersist() (stavba grafu k uložení)
EntityHydrator Front a Admin jen PHP reflexe + hydratační atributy hydrate(), hydrateAll(), hydrateWithReferences(), extract(), applyIdentity()

Obě jsou v app/Core/Utils/Hydrators/.

Rozdělení je v kódu dodržené beze zbytku

OrmEntityHydrator importuje 144 souborů, všechny v app/UI/Api/. EntityHydrator importuje 135 souborů v app/UI/Admin/ a 109 v app/UI/Front/ — a ani jeden v Api. Přepočítá se to takhle:

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

Front a Admin nesmí sáhnout na OrmEntityHydrator

Ten by přes EntityManager otevřel databázi z vrstvy, která k ní nemá mít přístup (Pětivrstvý model). Fungovalo by to — lokálně i na dev serveru, protože databáze je tam po ruce. Rozpadne se to teprve tam, kde Front běží jinde než Api, a do té doby se ta zkratka rozšíří.

Celý tok od dotazu k entitě#

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 entita

Podstatné je, kde je hranice: Api entita a Front entita jsou dvě různé třídy nad toutéž tabulkou. News existuje ve čtyřech podobách — app/Core/Base/Shared/Blog/… (abstraktní předek) plus varianta v Api/, Admin/ a Front/. Mezi nimi neteče objekt, ale pole.

Tvar dat na drátě#

{
  "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": "…" } }
  }
}
Klíč Význam
items / data seznam / jedna entita
totalCount celkový počet pro stránkování (jen když se o něj řeklo)
_references sdílené entity vytažené stranou, aby se neopakovaly u každé položky
{"id": 12} reference — hydrátor ji dohledá v _references, ne v databázi

hydrateAll() a hydrateWithReferences() nejdřív naplní _references do vnitřní cache a teprve pak hydratují položky. Api třídy z _references se přitom přepisují na cílový modul (\Api\\Front\ nebo \Admin\) podle toho, jakou entitu hydratujete.

hydrate() samotné _references nečte

Když si zavoláte hydrate() přímo na payloadu, který referenci obsahuje, cache je prázdná a asociace zůstane null. Nespadne to — jen se na stránce nezobrazí autor a nikde není chyba. Vstupní bod volte podle tvaru payloadu: seznam → hydrateAll(), jedna entita s obálkou → hydrateWithReferences().

Jak EntityHydrator pozná typy#

Doctrine metadata k dispozici nemá, takže si typ property odvodí z PHP reflexe. Kde to reflexe neunese, dodá informaci hydratační atribut:

Atribut Kdy je potřeba Co bez něj nastane
#[CollectionOf(Xxx::class, owned: true)] vždy u Collection / array typ položek se neurčí, kolekce zůstane prázdná
#[EntityOf(Xxx::class)] property je netypovaná nebo typovaná na sdílenou base třídu reference se hledá pod špatnou třídou a nenajde se
#[Identity] složená nebo odvozená identita (rodina *Text) fallback na property id — u single-PK entity stačí a atribut netřeba
#[ParentRef] zpětná vazba dítěte na rodiče extract() by se zacyklil / poslal redundantní data
#[Transient] počítaná property bez sloupce v DB hydrátor ji nabere reflexí a pošle ji do Api

owned: true u kolekce znamená vlastněná kolekce (Doctrine cascade: ['persist'] + orphanRemoval, typicky překlady): při extract() se serializuje s plnými daty dětí, aby je Api zápis mohl vložit i upravit. owned: false (výchozí, typicky M2M) pošle jen {"id": …} a dítě se na čtení dohledá z _references.

#[Identity] + #[ParentRef] na téže property se chová jinak

U odvozené identity (*Text má PK tvořený vazbou na rodiče) se vazba do payloadu musí dostat, jinak dítě ztratí vlastní identitu. Hydrátor to ošetřuje: když je property zároveň #[Identity], přeskočení kvůli ParentRef se neuplatní.

Postup: přidávám sloupec do entity#

  1. Sloupec do sdíleného předka v app/Core/Base/Shared/<Modul>/Models/Entities/protected typovaná property s #[ORM\Column(...)], getter a hned za ním setter.
  2. Nic dalšího dělat nemusíte, pokud jde o skalár: obě strany si typ vezmou z type-hintu.
  3. Prokliknout čtení i zápis. Čtení: hodnota se objeví na frontu. Zápis: uložit z administrace a ověřit, že v databázi opravdu je.

Skalární cizí klíč vedle asociace = dvě pravdy o jedné věci

Když má entita $userId (sloupec) i $user (asociaci), zapisujte jen jednu. Při zápisu obou vyhraje ta, kterou Doctrine zpracuje později — a která to je, záleží na pořadí klíčů v payloadu, ne na vašem kódu.

Postup: přidávám asociaci nebo kolekci#

  1. Do Api entity ORM mapování (#[ORM\ManyToOne], #[ORM\OneToMany]) — tady je zdroj pravdy o vazbě.
  2. Do Front a Admin varianty tutéž property plus hydratační atribut:
/** @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. Accessor pojmenujte přesně podle property$translationsgetTranslations().
  2. Cross-modulové ManyToOne piště bez inversedByOrmEntityHydrator takovou vazbu považuje za back-reference a při zápisu ji zahodí.
  3. Do include() ji přidejte tam, kde ji potřebujete číst; sama se nenačte.
  4. Ověřte obě strany — načtení (kolekce má prvky) i uložení (prvky přežijí save).

Accessor jinak pojmenovaný než property nespadne, jen tiše mlčí

EntityHydrator hledá setter podle názvu property. Při nesouladu kolekci nenaplní, Api ji neserializuje a admin formulář ji uloží prázdnou. Projeví se to jako „zmizely překlady“ o několik dní později, ne jako chyba při hydrataci.

Postup: ukládám entitu z administrace#

Cesta je vždycky stejná a je vidět v app/Core/Traits/Shared/Models/Mappers/SaveTrait.php:

$data   = $this->hydrator->extract($entity);      // 1. entita → ploché pole
$result = $this->repository->save($data, $meta);  // 2. přes bridge na Api endpoint
if ($result->isSuccess()) {
    $this->hydrator->applyIdentity($entity, $result->getData());   // 3. id zpátky
}
  1. extract() projde properties reflexí: vlastněné kolekce inline s plnými daty, ostatní asociace jako {"id": …}, #[ParentRef] a #[Transient] vynechá.
  2. Api endpoint …/save payload nejdřív ořeže o pole, na která volající nemá právo (#[ExternalReadonly], #[ExternalAdminOnly] — viz Atributy pro zápis sloupců) a teprve pak zavolá hydrateForPersist().
  3. hydrateForPersist() postaví spravovaný objektový graf, aby flush() sám vyřešil vložení, úpravu i smazání:
Situace Chování
entita s id dohledá se spravovaná a hydratuje se do ní (úprava)
bez id new (vložení)
vlastněná kolekce merge do kolekce rodiče, chybějící prvky se odeberou
zpětná vazba dítěte nastaví se z rodiče, ne z dat
ManyToMany kolekce se nahradí celá
  1. applyIdentity() zapíše vygenerované id zpátky do vaší entity, takže na ni jde hned navázat.

Uložení objektového grafu je NAHRAZENÍ, ne doplnění

Prvek kolekce, který v payloadu chybí, se smaže. Když formulář načte jen část kolekce (třeba překlady jednoho jazyka) a graf pak uloží, zbytek zmizí. Do includes proto patří celá kolekce, ne výběr z ní.

Filtrovaný include otráví celou kolekci

Načtení kolekce s filtrem vypadá jako úspora dotazu. Při následném uložení se ale všechno, co filtr odřízl, považuje za „už tam nemá být“ a smaže se. Filtrovat se má až nad načtenou kolekcí v PHP, ne při načítání ke zpracování.

Odebrání a znovupřidání téhož prvku = DELETE + INSERT

Nový řádek dostane jiné id. Kde na staré id někdo odkazuje (faktura, doklad, externí systém), odkaz se rozpadne — a odkaz na neexistující řádek se nezobrazí jako chyba, jen jako prázdno.

Postup: hydratace vrací null nebo prázdno#

Od nejlevnějšího:

  1. Podívejte se do logu na API_FAILSOFT. Když Api vrátilo neúspěšnou obálku (success => false), hydrátor ji odmítne prohlásit za data a vrátí null / [] — a zaloguje to i s tím, kdo o data požádal. Stránka se degraduje, nespadne.
  2. Ověřte vstupní bod. Payload se seznamem patří do hydrateAll(), jedna entita s obálkou do hydrateWithReferences(). hydrate() na obálce vrátí nesmysl.
  3. Zkontrolujte, že property má hydratační atribut. Prázdná kolekce u property bez #[CollectionOf] je přesně tenhle případ.
  4. Zkontrolujte název accessoru proti názvu property.
  5. Ověřte, že asociace je v include(). Bez toho ji Api do payloadu nedá.
  6. Teprve pak sahejte na HydratorDebugger (app/Core/Utils/Debug/HydratorDebugger.php) — vypíše, co hydrátor s jakým klíčem udělal.

Prázdná entita hodí výjimku, selhaná odpověď ne

hydrate() na datech, kde jsou samé null a prázdné id, hodí RuntimeException. Selhaná odpověď z Api naopak projde tiše jako null. Jsou to dva různé stavy a hledají se každý jinde.

Identity mapa#

Zápisová cesta má vlastní landminu, která s hydratací souvisí, ale je samostatná: Api repository vyprazdňuje Doctrine identity mapu na začátku každého čtení. Celé to má vlastní kapitolu — Identity mapa a clearDoctrine(). Přečtěte si ji dřív, než začnete psát lifecycle hooky.

Kam sáhnout#

Chci Kde
hydrátor Api vrstvy app/Core/Utils/Hydrators/OrmEntityHydrator.php
hydrátor Front/Admin app/Core/Utils/Hydrators/EntityHydrator.php
hydratační atributy app/Core/Attributes/Hydration/
generický save mapperu app/Core/Traits/Shared/Models/Mappers/SaveTrait.php
ořez práv při zápisu app/Core/Traits/Api/Presenters/SaveTrait.php
diagnostika hydratace app/Core/Utils/Debug/HydratorDebugger.php

Navazující kapitoly: Identity mapa a clearDoctrine() · Atributy pro zápis sloupců · Volání API · Pětivrstvý model