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

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#

  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 — $translations → getTranslations().
  2. Cross-modulové ManyToOne piště bez inversedBy — OrmEntityHydrator 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: 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
  1. Bez data v identitě vol surrogate klíč. Vlastnost s datem je pak \DateTime, ne \DateTimeImmutable — DBAL typ datetime immutable objekt nevezme (ten chce datetime_immutable).
  2. Musí-li datum do složeného klíče, mapuj ho jako type: 'string' ve formátu Y-m-d H:i:s (Y-m-d u DATE). Fyzické schéma se nemění, MySQL řetězec přijme.
  3. Platí to i pro read-only entitu nad pohledem (ProductSetCurrentPrice): i find() 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
}
  1. 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á
  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í.

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:

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

Nastavení cookies