Use PROACTIVELY when you implement, review, audit, debug, or optimize bulk operations (imports, mass edits, variant creation, batch updates) in Doctrine/Shopsys codebases. Triggers on: slow batch operations, 'Doctrine CPU spiral', N+1 queries in loops, creating/updating 100+ entities, flush discipline issues, or side effect problems. Covers: creating bulk handlers, reviewing performance issues, auditing existing code for anti-patterns, and refactoring single-item loops to bulk paths.
This guide is meant for teams working in a Shopsys / Symfony / Doctrine codebase where application code extends vendor framework code and where business operations often touch many tables (products, prices, visibilities, translations, URLs, exports, etc.).
The goal is not to "avoid Doctrine", but to use Doctrine in a way that stays predictable under load and remains maintainable for humans (and AI assistants) over time.
Treat each controller action / CLI command as an explicit "unit of work" with:
If you can't explain "what happens" in a short ordered list, the code is too implicit.
The #1 source of confusion is flushes happening deep in call stacks. It makes performance unpredictable and breaks reasoning (because a random helper can flush and trigger huge UnitOfWork work).
Team rule (recommended):
flush().flush() unless the method name clearly states it, e.g. saveAndFlush() / createAndFlush(), and it's reviewed as intentional.Any operation that can create/update N items (variants, imports, mass edit, batch status changes) must not reuse single-item Facade methods in a loop.
Looping "rich" facade methods usually multiplies:
Instead: implement a bulk API or a dedicated bulk service (e.g., *BulkCreator).
Even if you keep Doctrine ORM, your algorithm should feel like:
If the code needs repeated "ask DB for a little thing" inside loops, you'll get N+1 sooner or later.
persist() inside loops, flush() once at the end.clear() if you need to control memory.Use case hint: If an action creates 100+ items, a per-item flush will almost always produce "Doctrine CPU spiral" symptoms.
Use case hint: Visibility recalculation and price recalculation are classic "run once per family/batch", not "per item".
findOneBy() repeatedly for the same lookup pattern.Use case hint: Anything like "get translation/group/setting/price for each item" can hide N+1.
If your loop logic only needs IDs or scalars:
This reduces both memory and UnitOfWork overhead.
Large CRUD methods often do far more than "save fields". They may trigger:
Examples:
create() (no flush)createAndFlush() (flush inside, rare, justified)createBulk() / bulkInsert() (explicit bulk path)scheduleRecalculations() (explicit side effects)This helps reviewers and AI assistants understand what happens.
A maintainable structure in Shopsys typically looks like:
Introduce a simple policy object like:
BulkOperationOptions { immediateRecalc=false, immediateExport=false, chunkSize=200 }So code doesn't "accidentally" execute immediate work in bulk flows.
When reviewing a change in a "potential hot path" (mass edit/import/variant creation/list export):
Flush count
flush() called in loops? (Must be "no".)Query count
findOneBy() pattern?Lazy loading
Side effects
Memory & UnitOfWork
clear() used safely (not randomly)?This is the pattern that works best in Shopsys-style codebases: keep Doctrine for compatibility, but force explicit orchestration.
(Keeping this example relatively close to real use cases like "create variants", but the pattern applies to imports, mass edit, bulk status updates, etc.)
public function bulkCreateAction(Request $request): Response
{
// Validate input / form...
$command = new CreateManyItemsCommand(
parentId: (int) $request->get('id'),
// ...selected options...
);
$result = $this->createManyItemsHandler->handle($command);
// flash messages + redirect
}
final class CreateManyItemsHandler
{
public function handle(CreateManyItemsCommand $cmd): CreateManyItemsResult
{
return $this->em->wrapInTransaction(function () use ($cmd) {
// Phase 1: preload everything needed (no lazy loads later)
$parent = $this->parentRepository->getById($cmd->parentId);
$referenceMap = $this->referenceRepository->getMapFor($cmd);
// Phase 2: compute new items in memory (pure logic)
$newItemsData = $this->planner->plan($parent, $referenceMap, $cmd);
// Phase 3: persist in bulk (no per-item flush)
$newEntities = [];
foreach ($newItemsData as $data) {
$entity = $this->factory->create($data);
$this->em->persist($entity);
$newEntities[] = $entity;
}
$this->em->flush(); // one flush (or chunked)
// Phase 4: apply side effects explicitly, ideally once
// - create join-table rows in bulk
// - schedule exports/recalcs once
// - optionally run a scoped refresh for the "family"
$this->sideEffects->applyForBulkCreate($parent, $newEntities, $cmd->options);
return new CreateManyItemsResult(/* ids, counts, warnings */);
});
}
}
final class SideEffects
{
public function applyForBulkCreate($parent, array $children, BulkOptions $opts): void
{
if ($opts->createVisibilityRows) {
$this->visibilityWriter->bulkCreateRows($children);
}
if ($opts->createFriendlyUrls) {
$this->urlWriter->bulkCreateUrls($children);
}
// Prefer delayed work for bulk operations:
$this->scheduler->markForExport($parent, $children);
$this->scheduler->markForRecalculation($parent, $children);
if ($opts->immediateRefreshFamilyVisibility) {
$this->visibilityRefresher->refreshFamily($parent->getId());
}
}
}
Why this helps your whole team: One handler is "the truth" of the use case. No more guessing which facade triggers which flush or refresh.
A useful internal refactor pattern for bulk operations:
*BulkCreator / *Writer: does DB writes via DBAL, minimal policy, no flush (or flush only when explicitly asked)*Facade: composes factories, writers, and side effects under explicit optionsThis reduces the "one method does everything" anti-pattern and keeps Facades readable.
When analyzing routes or code paths, always produce a Route Flow Schema showing the call hierarchy with annotations:
ProductController::detailAction() [line 309]
├── ProductDetailViewFacade::getVisibleProductDetail($id) → Elasticsearch ✅
├── [IF isMainVariant] getVariantsParameterTemplate($product) ⚠️⚠️⚠️ CRITICAL
│ ├── ProductFacade::getById() - loads Doctrine entity
│ ├── $product->getParameterTemplate()->getParameters() - lazy load
│ ├── LOOP: parameterTemplateParameterPositionFacade->getParameterPositionInParameterTemplate()
│ ├── parameterFacade->extractProductParameterValuesDataIncludedVariants() ⚠️
│ │ └── LOOP for all variants: productParameterValueRepository queries
│ ├── LOOP: parameterFacade->getParameterValueById() ⚠️ N+1
│ └── LOOP: parameterFacade->getParameterValuesByGroupId() ⚠️ N+1
├── GtmFacade::onProductDetailPage()
├── Template: Front/Content/Product/detail.html.twig
│ ├── BreadcrumbController::indexAction()
│ ├── CartController::productActionAction() (x2)
│ └── [FOR variants loop] Heavy Twig iteration ⚠️
└── Layout (layoutWithoutPanel.html.twig)
├── [CACHED 24h] CategoryController::panelAction()
└── FlashMessageController::indexAction()
Annotation legend:
✅ = Good pattern (Elasticsearch, single query, proper batching)⚠️ = Concern (lazy loading, potential N+1, uncached heavy operation)⚠️⚠️⚠️ CRITICAL = Severe issue requiring immediate attention→ Elasticsearch = Data fetched from Elasticsearch (good)LOOP: = Operation inside a loop (watch for N+1)[IF condition] = Conditional branch[FOR x loop] = Template loop iteration[CACHED Xh] = Cached with duration[line N] = Source code line reference(xN) = Called N timesAfter the schema, provide: