@@ -63,6 +63,12 @@ class CwaFixtureBuilder
6363 /** Maps spl_object_id(GroupBuilder) → ComponentGroup for use in phase 4 */
6464 private array $ componentGroupMap = [];
6565
66+ /** Tracks object IDs already passed to persist() to avoid cycles in persistWithAssociations() */
67+ private array $ persistedEntities = [];
68+
69+ /** Phases 1–3 run exactly once; phase 4 runs on every flush() call to pick up new positions */
70+ private bool $ initialFlushDone = false ;
71+
6672 public function __construct (
6773 private readonly TimestampedDataPersister $ timestampedPersister ,
6874 private readonly RouteGeneratorInterface $ routeGenerator ,
@@ -157,9 +163,14 @@ public function pageData(
157163 return $ builder ;
158164 }
159165
166+ /**
167+ * Explicitly persist an entity and walk its owning-side associations to persist related objects.
168+ * Use this for app-specific entities that the builder doesn't manage (e.g. HtmlContent set on a PageData).
169+ * Does not rely on Doctrine cascade — every related object is persisted explicitly.
170+ */
160171 public function persist (object $ entity ): static
161172 {
162- $ this ->manager -> persist ($ entity );
173+ $ this ->persistWithAssociations ($ entity );
163174
164175 return $ this ;
165176 }
@@ -173,12 +184,20 @@ public function getRoute(string $routeName): Route
173184 return $ this ->namedRoutes [$ routeName ];
174185 }
175186
187+ /**
188+ * Phases 1–3 run exactly once (on first call).
189+ * Phase 4 runs every call to pick up positions added after the first flush (e.g. nav links added after routes exist).
190+ */
176191 public function flush (): void
177192 {
178- $ this ->phaseOne ();
179- $ this ->evaluateNested ();
180- $ this ->phaseTwo ();
181- $ this ->phaseThree ();
193+ if (!$ this ->initialFlushDone ) {
194+ $ this ->phaseOne ();
195+ $ this ->evaluateNested ();
196+ $ this ->phaseTwo ();
197+ $ this ->phaseThree ();
198+ $ this ->initialFlushDone = true ;
199+ }
200+
182201 $ this ->phaseFour ();
183202 }
184203
@@ -211,8 +230,9 @@ private function evaluateNested(): void
211230
212231 $ newPageDataSpecs = \array_slice ($ this ->pageDataSpecs , $ existingPageDataCount );
213232 foreach ($ newPageDataSpecs as $ spec ) {
214- $ this ->timestampedPersister ->persistTimestampedFields ($ spec ['builder ' ]->getPageData (), true );
215- $ this ->manager ->persist ($ spec ['builder ' ]->getPageData ());
233+ $ pageData = $ spec ['builder ' ]->getPageData ();
234+ $ this ->timestampedPersister ->persistTimestampedFields ($ pageData , true );
235+ $ this ->persistWithAssociations ($ pageData );
216236 $ hasNew = true ;
217237 }
218238
@@ -224,7 +244,7 @@ private function evaluateNested(): void
224244 $ page ->layout = $ layoutBuilder ->getLayout ();
225245 }
226246 $ this ->timestampedPersister ->persistTimestampedFields ($ page , true );
227- $ this ->manager -> persist ($ page );
247+ $ this ->persistWithAssociations ($ page );
228248 $ hasNew = true ;
229249 }
230250
@@ -238,7 +258,7 @@ private function phaseOne(): void
238258 foreach ($ this ->layoutBuilders as $ layoutBuilder ) {
239259 $ layout = $ layoutBuilder ->getLayout ();
240260 $ this ->timestampedPersister ->persistTimestampedFields ($ layout , true );
241- $ this ->manager -> persist ($ layout );
261+ $ this ->persistWithAssociations ($ layout );
242262 }
243263
244264 foreach ($ this ->pageSpecs as $ spec ) {
@@ -248,7 +268,7 @@ private function phaseOne(): void
248268 $ page ->layout = $ layoutBuilder ->getLayout ();
249269 }
250270 $ this ->timestampedPersister ->persistTimestampedFields ($ page , true );
251- $ this ->manager -> persist ($ page );
271+ $ this ->persistWithAssociations ($ page );
252272 }
253273
254274 foreach ($ this ->pageDataSpecs as $ spec ) {
@@ -257,7 +277,7 @@ private function phaseOne(): void
257277 $ pageData ->page = $ this ->pageSpecs [$ spec ['templateRef ' ]]['builder ' ]->getPage ();
258278 }
259279 $ this ->timestampedPersister ->persistTimestampedFields ($ pageData , true );
260- $ this ->manager -> persist ($ pageData );
280+ $ this ->persistWithAssociations ($ pageData );
261281 }
262282
263283 $ this ->manager ->flush ();
@@ -313,6 +333,9 @@ private function phaseThree(): void
313333 foreach ($ this ->orderedRouteSpecs as $ spec ) {
314334 if ('page ' === $ spec ['type ' ]) {
315335 $ page = $ spec ['builder ' ]->getPage ();
336+ if (null !== $ page ->getRoute ()) {
337+ continue ;
338+ }
316339 if (null !== $ spec ['route ' ]) {
317340 $ route = $ this ->createExplicitRoute ($ spec ['route ' ], $ spec ['routeName ' ]);
318341 $ route ->setPage ($ page );
@@ -330,6 +353,9 @@ private function phaseThree(): void
330353 }
331354 } else {
332355 $ pageData = $ spec ['builder ' ]->getPageData ();
356+ if (null !== $ pageData ->getRoute ()) {
357+ continue ;
358+ }
333359 if (null !== $ spec ['route ' ]) {
334360 $ route = $ this ->createExplicitRoute ($ spec ['route ' ], $ spec ['routeName ' ]);
335361 $ route ->setPageData ($ pageData );
@@ -385,19 +411,19 @@ private function createPositions(GroupBuilder $groupBuilder): bool
385411
386412 $ hasAny = false ;
387413
388- foreach ($ groupBuilder ->getComponents () as $ item ) {
414+ foreach ($ groupBuilder ->getNewComponents () as $ item ) {
389415 $ component = $ item ['component ' ];
390416 $ position = new ComponentPosition ();
391417 $ position ->sortValue = $ item ['sort ' ];
392418 $ position ->component = $ component ;
393419 $ componentGroup ->addComponentPosition ($ position );
394420 $ this ->timestampedPersister ->persistTimestampedFields ($ position , true );
395- $ this ->manager -> persist ($ component );
421+ $ this ->persistWithAssociations ($ component );
396422 $ this ->manager ->persist ($ position );
397423 $ hasAny = true ;
398424 }
399425
400- foreach ($ groupBuilder ->getPageDataPositions () as $ item ) {
426+ foreach ($ groupBuilder ->getNewPageDataPositions () as $ item ) {
401427 $ position = new ComponentPosition ();
402428 $ position ->sortValue = $ item ['sort ' ];
403429 $ position ->pageDataProperty = $ item ['property ' ];
@@ -425,4 +451,57 @@ private function deriveRouteName(string $path): string
425451
426452 return $ slug ?: 'root ' ;
427453 }
454+
455+ /**
456+ * Persists an entity and recursively persists all owning-side associated objects.
457+ * Does not rely on Doctrine cascade — every object is persisted explicitly.
458+ * Uses spl_object_id tracking to prevent cycles.
459+ */
460+ private function persistWithAssociations (object $ entity ): void
461+ {
462+ $ oid = spl_object_id ($ entity );
463+ if (isset ($ this ->persistedEntities [$ oid ])) {
464+ return ;
465+ }
466+ $ this ->persistedEntities [$ oid ] = true ;
467+ $ this ->manager ->persist ($ entity );
468+
469+ try {
470+ $ metadata = $ this ->manager ->getClassMetadata ($ entity ::class);
471+ foreach ($ metadata ->getAssociationNames () as $ assocName ) {
472+ if ($ metadata ->isAssociationInverseSide ($ assocName )) {
473+ continue ;
474+ }
475+ $ related = $ this ->readProperty ($ entity , $ assocName );
476+ if (null === $ related ) {
477+ continue ;
478+ }
479+ if (is_iterable ($ related )) {
480+ foreach ($ related as $ item ) {
481+ if (is_object ($ item )) {
482+ $ this ->persistWithAssociations ($ item );
483+ }
484+ }
485+ } else {
486+ $ this ->persistWithAssociations ($ related );
487+ }
488+ }
489+ } catch (\Exception ) {
490+ // Entity class not in Doctrine metadata (e.g. during unit tests with stubs)
491+ }
492+ }
493+
494+ private function readProperty (object $ entity , string $ property ): mixed
495+ {
496+ $ class = new \ReflectionClass ($ entity );
497+ do {
498+ if ($ class ->hasProperty ($ property )) {
499+ $ prop = $ class ->getProperty ($ property );
500+
501+ return $ prop ->isInitialized ($ entity ) ? $ prop ->getValue ($ entity ) : null ;
502+ }
503+ } while ($ class = $ class ->getParentClass ());
504+
505+ return null ;
506+ }
428507}
0 commit comments