Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Merge Persistent Collection Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. You are on Symfony + Doctrine and can use the bundle as designed.
  2. You are on Laravel + Doctrine ORM (DDD/hexagonal layout, or an incremental Eloquent→Doctrine migration), in which case you consume the logic, not the package.

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.
  • Tests, if any — they double as your characterization suite for the edge cases (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.

Implementation Patterns

Pattern 1 — Vendored merge helper (Laravel + Doctrine, recommended)

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.

Pattern 2 — Symfony bundle registration (if you are actually on Symfony)

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.

Pattern 3 — Explicit reconciliation instead of 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.

Pattern 4 — Queue consumers and long-running workers

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();
    }
}

Pattern 5 — Import / ETL / batch reconciliation

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();
}

Pattern 6 — Testing harness around the merge path

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.

Gotchas and Tips

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:

  1. Enable the SQL logger (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.
  2. Dump collection state before flush(), comparing $managed->getChildren() against the detached payload — sorted arrays of IDs, not objects.
  3. Verify the merge seam was actually invoked. A vendored helper that is not bound in the container fails silently back to EntityManager::merge().
  4. Reproduce in isolation. Most reported "merge bugs" are mapping problems (missing cascade, unset owning side) that the package cannot fix.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource