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:
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#
- Sloupec do sdíleného předka v
app/Core/Base/Shared/<Modul>/Models/Entities/—protectedtypovaná property s#[ORM\Column(...)], getter a hned za ním setter. - Nic dalšího dělat nemusíte, pokud jde o skalár: obě strany si typ vezmou z type-hintu.
- 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#
- Do Api entity ORM mapování (
#[ORM\ManyToOne],#[ORM\OneToMany]) — tady je zdroj pravdy o vazbě. - 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;
- Accessor pojmenujte přesně podle property —
$translations→getTranslations(). - Cross-modulové
ManyToOnepiště bezinversedBy—OrmEntityHydratortakovou vazbu považuje za back-reference a při zápisu ji zahodí. - Do
include()ji přidejte tam, kde ji potřebujete číst; sama se nenačte. - 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
}
extract()projde properties reflexí: vlastněné kolekce inline s plnými daty, ostatní asociace jako{"id": …},#[ParentRef]a#[Transient]vynechá.- Api endpoint
…/savepayload 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(). hydrateForPersist()postaví spravovaný objektový graf, abyflush()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á |
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:
- 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. - Ověřte vstupní bod. Payload se seznamem patří do
hydrateAll(), jedna entita s obálkou dohydrateWithReferences().hydrate()na obálce vrátí nesmysl. - Zkontrolujte, že property má hydratační atribut. Prázdná kolekce u property
bez
#[CollectionOf]je přesně tenhle případ. - Zkontrolujte název accessoru proti názvu property.
- Ověřte, že asociace je v
include(). Bez toho ji Api do payloadu nedá. - 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