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().
hydrateAll() i hydrateWithReferences() vrací jen entity hlavní třídy, i když
_references nese i jinou (u prací WorkItem třeba WorkSheet, podle kterého front
seskupuje). Hydratované jsou v cache stejně; ven je vytáhne
EntityHydrator::getCached(WorkSheet::class) — s cílovou Front/Admin třídou, ne Api.
Vzor je app/UI/Front/Company/Models/Mappers/Api/WorkItemMapper.php.
getCached() volej hned po hydrataci téhož payloadu
Obě hydratační metody začínají clearCache(). Volání bez předchozí hydratace ve
stejném requestu, nebo až po hydrataci jiné odpovědi, vrátí [] nebo entity z cizího
payloadu.
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: navrhuji primární klíč vlastněné entity#
Hydrátor páruje odeslané dítě vlastněné kolekce s existujícím podle složek identity
převedených na řetězec (OrmEntityHydrator::findExistingChild(), idKey()). Co se
nespáruje, je pro něj nový řádek — a starý smaže orphanRemoval. Proto:
| Situace | Klíč | Příklad |
|---|---|---|
| víc řádků na tutéž dvojici (multi-hodnotový parametr) | surrogate *_id |
ProductSetParameterRel |
| časová řada ve vlastněné kolekci, kterou ukládá formulář | surrogate *_id, datum je běžný sloupec type: 'datetime' |
ShipperTariffPrice, PaymentMethodPrice |
| datum musí být součástí složeného klíče | sloupec zůstává DATETIME/DATE, v ORM type: 'string' |
ProductSetPrice::$publishedTime, ProductSetFeePrice::$publishedDatetime, ProductSetStatistic::$date |
- Bez data v identitě vol surrogate klíč. Vlastnost s datem je pak
\DateTime, ne\DateTimeImmutable— DBAL typdatetimeimmutable objekt nevezme (ten chcedatetime_immutable). - Musí-li datum do složeného klíče, mapuj ho jako
type: 'string've formátuY-m-d H:i:s(Y-m-du DATE). Fyzické schéma se nemění, MySQL řetězec přijme. - Platí to i pro read-only entitu nad pohledem (
ProductSetCurrentPrice): ifind()a DQL registrují entitu v identity mapě.
DateTime ve složeném klíči shodí každý persist() i find()
UnitOfWork::getIdHashByIdentifier() dělá implode() přes složky identifikátoru
a DateTime se na řetězec převést nedá — fatální chyba „Object of class DateTime could
not be converted to string“.
Složený klíč s datem ve vlastněné kolekci tiše smaže historii
Stačí, aby se formát data v payloadu lišil od uloženého (sekundy, časová zóna, Y-m-d
proti Y-m-d H:i:s). Hydrátor odeslaný řádek nespáruje, vloží ho jako nový
a orphanRemoval smaže celou dosavadní řadu — bez chyby.
Složená identita sloučí řádky se stejným klíčem
Multi-hodnotový parametr drží víc řádků na (sada, parametr). S klíčem (sada, parametr) by je identity mapa při čtení zkolabovala do jednoho a ostatní hodnoty by zmizely.
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á.
!!! danger "Id vazby z payloadu extract() nečti přes (int)"
Vazba „na jednoho“ má v payloadu tvar ['id' => ['id' => 67]] (viz
tests/Unit/Hydrators/EntityHydratorExtractTest.php). (int) na pole vrátí 1,
ne chybu, takže (int) ($data['advert']['id'] ?? 0) dá 1 a zápis se tiše provede
u záznamu č. 1. Kdo v repository z payloadu vytahuje id vazby, rozbaluje klíč id,
dokud je hodnota pole, a převod nechá na OwnershipPayload::normalizeId() (vzor
AdvertFollowerRepository::referenceId()).
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 o pole, která nesmí zapsat
nikdo (#[FieldAccess] s Audience::None, např. heslo uživatele — viz
Postup: přidat nové tajné pole),
a na externí cestě i o pole mimo zápisové publikum volajícího (osy insert/update),
a teprve pak zavolá hydrateForPersist(). Řetězce z externího požadavku převede podle
typu pole, viz Převod hodnot při zápisu.
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á |
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í.
Formulář, který kolekci staví znovu, musí přenést id uložených řádků
Hydrátor páruje děti vlastněné kolekce podle id. Nové objekty bez něj jsou pro něj
nové řádky: každé uložení i beze změny pak smaže a znovu vloží celou kolekci a řádky
dostanou nová id. Vzor je ParameterFieldsTrait::applyProductSetParameterRels()
v administraci e-shopu — přednostně řádek se stejnou hodnotou, jinak kterýkoli zbylý
téhož klíče.
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.
Převod hodnot při zápisu#
Do OrmEntityHydrator přicházejí hodnoty dvojího původu: z interní cesty (ApiBridge) nativní
typy a z externího HTTP jen řetězce — data jde v query stringu, takže ?data[currencyId]=1
přinese '1'. Typovaný setter (setCurrencyId(?int)) by na řetězci spadl na TypeError.
Řetězec do pole, které není string, proto hydrátor převede přes
App\Core\Utils\Hydrators\StringValueCoercer. Cílový typ bere z parametru setteru, když chybí
(nebo je mixed), z typu vlastnosti, a nakonec z Doctrine mapování sloupce. Sjednocený typ
(int|string) se nepřevádí.
| Cíl | Přijme | Odmítne |
|---|---|---|
int |
kanonické celé číslo (-12, 0) |
1.9, 1e2, +1, 01, mezery, přetečení |
float |
číselný zápis (-1.5, .5, 2e3) |
čárku, mezery, přetečení (1e999) |
bool |
1, 0, true, false, on, off (malými písmeny) |
cokoli jiného |
DateTime, DateTimeImmutable |
co přijme new DateTime() v časovém pásmu aplikace |
přetečené datum (2026-02-31), okrajové mezery |
výčet s int podkladem |
kanonické celé číslo, výčet z něj udělá hydrátor | |
výčet se string podkladem, string |
řetězec projde beze změny |
'' u typu s null znamená null, u typu bez null chybu. Nepřevoditelná hodnota vyhodí
InvalidFieldValueException se jménem pole, očekávaným typem a (zkrácenou) hodnotou. Api
SaveTrait ji chytá zvlášť: uklidí EntityManager a vrátí INVALID_DATA (604) se zprávou
Invalid value for field "…": expected …, got "…". — ne ITEM_NOT_FOUND jako u ostatních
HydrationException.
Převod otevírá pole, která dřív chránila chyba
Dokud se řetězce nepřeváděly, externí zápis číselných polí padal — a tím náhodou chránil
i kredity nebo slevu uživatele. Převod proto přišel až se zámkem polí podle zápisového
publika (Atributy pro zápis sloupců).
Nové číselné pole entity s owner grantem zkontroluj proti OwnerWritableFieldsTest.
Nativní datum z interní cesty (DateTime i DateTimeImmutable) je hodnota, ne vnořená
entita: hydrátor ho předá setteru a jen převede mutable ↔ immutable podle cílového typu. Dřív
DateTimeImmutable spadl do hydratace vnořené entity a setter dostal null.
Když zápis odmítne payload#
Čtecí cesta je benevolentní: nedohledaná reference skončí null a jen se zaloguje.
Zápisová cesta (hydrateForPersist()) naopak raději odmítne celý zápis, protože
tiché null by tam bylo destruktivní — vynulovaná ToOne vazba, položka vypadlá z M:N
nebo dítě vlastněné kolekce smazané přes orphanRemoval.
| Situace v payloadu | Výjimka | Výsledek Api save |
|---|---|---|
reference {"id": X}, kterou nenajde cache ani databáze |
ReferenceNotFoundException |
ITEM_NOT_FOUND (616) |
plná data s konkrétním id entity s generátorem (AUTO/IDENTITY/SEQUENCE), která v databázi není |
HydrationException („refusing to resurrect“) |
ITEM_NOT_FOUND (616) |
| dítě vlastněné kolekce, které patří jinému rodiči | HydrationException |
ITEM_NOT_FOUND (616) |
| hodnota, kterou nejde převést na typ pole | InvalidFieldValueException |
INVALID_DATA (604) |
jiná chyba hydratace (typicky TypeError v setteru) |
cokoli jiného | MISSING_REQUIRED_FIELD (602), výjimka v logu save.hydration |
Výjimkou je zástupný identifikátor 0, '0', null nebo '': znamená ještě
neuloženého rodiče (nebo novou entitu) a projde bez chyby. Entita s ručně přiděleným
klíčem (bez generátoru, třeba odvozená identita) se s explicitním id normálně vloží.
Smazaný záznam nejde „uložit znovu“ pod starým id
Generátor by požadované id ignoroval a vložil řádek pod jiným, klient by se to
nedozvěděl. Proto zápis s neexistujícím id skončí ITEM_NOT_FOUND; nový záznam se posílá bez id
(nebo s 0).
Po chybě se EntityManager vyčistí
Hydratace pracuje přímo nad identity mapou, takže před výjimkou mohla změnit
spravované entity. Api SaveTrait proto ve všech třech větvích volá
clearEntityManager(); jinak by je další flush() v témže požadavku tiše zapsal.
Kdo volá hydrateForPersist() mimo SaveTrait, musí totéž udělat sám.
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á: Doctrine identity mapa se vyprazdňuje na hranicích operace (jedno Api volání, akce Cronu, krok skriptu), ne při čtení. Celé to má vlastní kapitolu — Identity mapa a hranice operace. 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 hranice operace · Atributy pro zápis sloupců · Volání API · Pětivrstvý model