lenny4/doctrine-merge-persistent-collection-bundle
A Symfony bundle that provides a merge-persistent Doctrine collection, helping entities keep collection state across merges and reducing unexpected orphan removals during Doctrine entity merging operations.
First, resolve the stack mismatch. This is a Symfony bundle (*Bundle suffix, DI extension, bundles.php registration) built for Doctrine ORM. It has no Laravel service provider and no Eloquent integration. In a pure Eloquent application, stop here — there is nothing to install, and pulling Doctrine in just to use it is not proportionate. The remainder of this guide assumes one of two situations:
Where to look first. Clone the repo and read in this order:
src/ — find the merge strategy service (the class that intercepts or replaces collection merging). This is the entire value of the package; everything else is glue.DependencyInjection/ and Resources/config/services.yaml — tells you what it replaces. Is it a Doctrine event subscriber? A custom PersistentCollection subclass? A decorated UnitOfWork? That determines how invasive the vendoring will be.composer.json — check doctrine/orm, doctrine/collections, php, and symfony/* constraints before anything else. Constraint conflicts are the most likely reason you cannot adopt this at all.orphanRemoval, composite keys, ordered collections).An afternoon of reading is the right investment; the surface area is small enough to audit fully.
Reproduce the bug before adopting the fix. The first "use case" is not installing the package — it is proving you have the problem. Write a failing test:
// 1. Load a managed Order with 3 line items.
$order = $em->find(Order::class, $id);
$em->clear();
// 2. Hydrate a detached graph from a request payload (2 existing items + 1 new).
$detached = $this->hydrator->hydrate(Order::class, $payload);
// 3. Merge.
$merged = $em->merge($detached);
$em->flush();
// 4. Assert. Default Doctrine will typically leave you with 5 rows, not 3.
$this->assertCount(3, $em->getRepository(LineItem::class)->findBy(['order' => $id]));
If that test passes on stock Doctrine with your mappings, you do not need this package. If it produces duplicate rows, an orphanRemoval surprise, or A new entity was found through the relationship — you have a concrete target, and the repo's implementation becomes your reference.
Typical first case: a PUT /orders/{id} endpoint where the client submits the complete child collection (line items, tags, permissions, addresses) rather than a per-item diff.
Do not add this to composer.json. Copy the merge logic into your application under MIT attribution and own it:
// app/Support/Doctrine/MergePersistentCollection.php
// Adapted from lenny4/doctrine-merge-persistent-collection-bundle (MIT).
// See: https://github.com/Lenny4/doctrine-merge-persistent-collection-bundle
final class MergePersistentCollection
{
public function merge(EntityManagerInterface $em, object $detached): object
{
// Reconcile each collection by identifier rather than clear()+re-add.
// ...
}
}
Bind it in a service provider:
// app/Providers/DoctrineServiceProvider.php
$this->app->singleton(MergePersistentCollection::class);
Wrap it behind a single application-level seam so callers never touch EntityManager::merge() directly:
final class EntityReattacher
{
public function __construct(
private EntityManagerInterface $em,
private MergePersistentCollection $merger,
) {}
public function reattach(object $detached): object
{
return $this->merger->merge($this->em, $detached);
}
}
The seam matters more than the implementation: it gives you one place to swap strategies when Doctrine bumps a major version.
Three steps and you are done:
// config/bundles.php
return [
// ...
Lenny4\DoctrineMergePersistentCollectionBundle\DoctrineMergePersistentCollectionBundle::class => ['all' => true],
];
Then use EntityManager::merge() exactly as before — the bundle is transparent by design. No config file is required; check Resources/config/ for optional knobs before assuming defaults are right for you.
merge() (the durable pattern)For any new endpoint, prefer this over either the package or a vendored copy. It removes the whole bug class:
public function update(Order $managed, OrderPayload $payload): Order
{
$existing = $managed->getLineItems()->indexBy(fn ($i) => $i->getId());
// Remove children no longer present. orphanRemoval handles the DELETE.
foreach ($existing as $id => $item) {
if (! $payload->hasLineItem($id)) {
$managed->removeLineItem($item);
}
}
// Update in place, add new — never clear() and re-add.
foreach ($payload->lineItems as $data) {
$item = $data->id ? $existing[$data->id] : new LineItem();
$item->setSku($data->sku)->setQty($data->qty);
$managed->addLineItem($item); // idempotent add
}
return $managed;
}
More lines than merge(), but no detached-graph semantics, no cross-EntityManager proxies, no silent duplication.
Messages deserialize into detached entities; reattachment happens per job at volume. Structure the consumer so the merge is bounded and observable:
public function handle(SyncOrder $job): void
{
$this->em->beginTransaction();
try {
$managed = $this->reattacher->reattach($this->em, $job->order);
$this->em->flush();
$this->assertCollectionIntegrity($managed); // row-count check, see Gotchas
$this->em->commit();
} catch (\Throwable $e) {
$this->em->rollback();
throw $e;
} finally {
// Critical in workers: prevents the identity map from accumulating
// stale entities across jobs and masking merge bugs.
$this->em->clear();
}
}
Same seam, different loop shape. Batch in chunks, clear() between chunks, and never hold a huge PersistentCollection initialized in memory across the whole run — merging is O(n) per association and loads the full collection.
foreach (array_chunk($rows, 200) as $chunk) {
foreach ($chunk as $row) {
$this->reattacher->reattach($this->em, $this->hydrateFromRow($row));
}
$this->em->flush();
$this->em->clear();
}
Your test suite, not the package, is the support contract. For every entity graph that goes through the merge seam, assert:
$before = $this->countRows(LineItem::class);
// ... merge + flush ...
$this->assertSame($expectedCount, $this->countRows(LineItem::class));
$this->assertSame(0, $this->countOrphans(LineItem::class));
$this->assertSame($expectedOrder, $managed->getLineItems()->map->getId()->all());
Cover the four dangerous shapes explicitly: orphanRemoval: true, @OrderBy, composite/assigned identifiers, and bidirectional mappedBy/inversedBy.
merge() does not throw when it is wrong. This is the single most important fact. Incorrect collection merging produces duplicate rows, orphaned children, or dropped elements that pass functional tests and surface weeks later in reporting. Treat any change to the merge path as a data-integrity change, not a refactor.
Check composer constraints before anything else. Likely first blocker: doctrine/orm major version. ORM 3.x changed collection and Unit of Work semantics meaningfully; a small bundle pinned to 2.x may be uninstallable on a modern stack — and because Composer resolves the whole graph, Symfony 4/5 constraints can fail even in a non-Symfony app. Do not --ignore-platform-reqs past this; that converts a dependency problem into a runtime problem.
PersistentCollection vs ArrayCollection is asymmetric. A freshly hydrated detached entity holds a plain ArrayCollection; the managed entity holds a PersistentCollection backed by the UoW. The default merge path re-adds rather than reconciles, which is exactly the failure. If you vendor, replicate by matching on identifier, not by clear() + re-add.
orphanRemoval: true turns a merge bug into unintended DELETEs. If the merged collection omits a child the managed collection still has, the fix can cause a delete you did not intend. Assert orphan counts in tests, and be conservative: do not merge collections you have not explicitly modelled as full-replacement.
Set the owning side in bidirectional associations. In mappedBy/inversedBy pairs, Doctrine persists the owning side only. A vendored helper that only updates the inverse collection will silently no-op. After merging, walk the collection and call $child->setParent($parent) on every element.
Assigned/composite identifiers cannot be auto-matched. The reconcile-by-ID strategy requires the child to carry a stable identifier. With composite keys or assigned IDs, merge() may try to insert instead of update. Either add a surrogate key or reconcile using the natural key explicitly.
cascade: [merge] must be on the association. If the mapping only cascades persist, merge() will not descend into the collection at all, and you will chase a bug in the wrong layer. Check the mapping before blaming the package.
Doctrine's identity map will bite you between requests. In workers and queue consumers, calling merge() on an entity graph where a same-ID instance is already managed can yield stale collection contents. $em->clear() after each unit of work is not optional.
Debugging checklist when collections go wrong:
doctrine.dbal.logging: true) and read the actual INSERT/DELETE stream. Duplicate INSERT INTO line_item with fresh IDs is the signature of the re-add bug.flush(), comparing $managed->getChildren() against the detached payload — sorted arrays of IDs, not objects.EntityManager::merge().Extension points are UnitOfWork internals — treat them as unstable. If the package hooks onFlush, preUpdate, or decorates collection classes, any Doctrine minor bump can change the contract. Pin doctrine/orm while vendored code is in use, and add a Doctrine-upgrade check to your dependency review.
Supply chain: this fails any reasonable bar as a live dependency. One star, zero dependents, single maintainer, no published support commitment. MIT removes legal friction; vendor it, credit it, own the release cadence. Assign a named steward in your dependency register and set a review date.
When to skip the whole thing. Greenfield projects should not choose detached-merge workflows at all. If you can load the managed entity and reconcile against the payload (Pattern 3), do that — it is more code but removes this entire class of bug rather than patching around it.
How can I help you explore Laravel packages today?