Shopware Promotion Bug: lineItemNotFound on Discount Removal

The client needed a campaign landing page feature, which I built as a custom plugin. On the landing page, customers entered their promo code into a field (it wasn’t auto-added) and chose their desired size for the product configured in the plugin settings. Adding that size placed the item into the cart along with the promotion discounts.

That part worked as expected on the first build.

Then I opened the cart during testing, clicked remove on one of the discount rows, and got a generic error message. The discount stayed exactly where it was.

Because I developed the custom plugin and the feature myself, it was naturally the first place I investigated. But the plugin code was completely fine: the issue was a deep bug in Shopware core’s promotion subscriber.

The loop that eats its own list

When a promotion line item is removed, Shopware’s StorefrontCartSubscriber cleans up the rest of that promotion. Every other discount line item carrying the same promotionId goes too, and each removal fires a BeforeLineItemRemovedEvent.

Here is the code as it shipped:

$lineItemsOfSamePromotion = $cart->getLineItems()
    ->filter(static fn (LineItem $lineItem) => $lineItem->getType() === PromotionProcessor::LINE_ITEM_TYPE
        && $lineItem->getPayloadValue('promotionId') === $removedLineItem->getPayloadValue('promotionId'));

foreach ($lineItemsOfSamePromotion as $lineItemOfSamePromotion) {
    $cart->remove($lineItemOfSamePromotion->getId());

    $this->eventDispatcher->dispatch(new BeforeLineItemRemovedEvent($lineItemOfSamePromotion, $cart, $context));
}

Read the dispatch line again. The subscriber that runs this loop is itself listening for BeforeLineItemRemovedEvent. So the dispatch calls the method again, from inside the method.

The nested call builds its own list of siblings, and by then the outer loop has only removed one of them. The nested call happily removes the rest. Control comes back to the outer loop, which is still walking a collection it snapshotted before any of this started. It reaches the next sibling and calls $cart->remove() on an ID that no longer exists.

Cart::remove() has an opinion about that:

public function remove(string $key): void
{
    $item = $this->get($key);

    if (!$item) {
        throw CartException::lineItemNotFound($key);
    }
    ...
}

Two sibling discounts are enough to trigger it. One is not, because there is nothing left to iterate over after the first removal. Most promotions in most shops produce exactly one discount line item, which is why this sat in a release line for a year without a bug report.

How it got there

The interesting part is the history.

Before August 2025, the same method took only the first sibling and removed it through CartService, with a comment saying the recursion was deliberate:

// this is recursive because we are listening on LineItemRemovedEvent, it will stop if there
// are no discounts in the cart, that belong to the promotion that should be deleted
$this->cartService->remove($cart, $promotionLineItem->getId(), $context);

One item, then recurse, then let the recursion stop when the filter comes back empty. It reads oddly, but the two halves agreed with each other.

Then PR #11788 reworked the subscriber to stop calling the full remove route from inside an event listener. That call had started deadlocking. The remove route takes a lock on the cart, so re-entering it from its own event meant waiting on a lock the outer call already held. Shops got “Cart is locked due to concurrent write operation” where they expected a removed discount, and the trigger was the same two-discount promotion. Good change on its own terms. It swapped the single-item recursion for a foreach over the whole filtered list, and kept dispatching the event inside the loop.

So the recursion stayed, and iteration over a stale snapshot was added on top. The two mechanisms now do the same job twice and disagree about what is left in the cart.

That change shipped in 6.7.2.0 on 1 September 2025.

What the customer would have seen

The customer never sees a stack trace, which is part of why this is easy to miss.

CartLineItemController::deleteLineItem wraps the whole thing in try { ... } catch (\Exception) and drops a generic danger flash. The customer clicks remove, gets “something went wrong”, and the discount is still sitting in the cart. The exception escapes CartItemRemoveRoute::remove() before calculate() and save() ever run, so nothing is written and the cart reloads exactly as it was.

Store API and headless clients get a proper HTTP 400 with CHECKOUT__LINE_ITEM_NOT_FOUND. Those callers at least get told what happened.

On a campaign landing page you are buying traffic for, “something went wrong” at the cart is a bad place to spend the click.

The fix

Six lines, one of which does the work:

foreach ($lineItemsOfSamePromotion as $lineItemOfSamePromotion) {
    // a sibling discount may already have been removed by a nested call to this
    // method, triggered by the event dispatched below for an earlier sibling
    if (!$cart->has($lineItemOfSamePromotion->getId())) {
        continue;
    }

    $cart->remove($lineItemOfSamePromotion->getId());

    $this->eventDispatcher->dispatch(new BeforeLineItemRemovedEvent($lineItemOfSamePromotion, $cart, $context));
}

Ask the cart whether the item is still there before removing it. Whichever call gets to a sibling first wins, the other one skips it, and the event fires once per line item instead of twice.

The test was more interesting than the fix.

Every other test in that file hands the subscriber a stubbed dispatcher, either a collecting stub that records events or a PHPUnit mock. Neither one dispatches anything. Against either, the buggy code passes. The nested call never happens, so the outer loop never trips over its own work.

The regression test had to use a real Symfony EventDispatcher with the subscriber registered on it, three sibling discounts in the cart, and the removal driven through an actual dispatch. That is the only setup where the bug exists at all.

Worth keeping: a test double that swallows dispatch() will hide every re-entrancy bug in the code under it, and it will do so quietly.

When the fix ships

Opened 12 August 2026. Merged 17 August 2026. Tagged for milestone 6.7.14.0.

The newest release right now is 6.7.13.0, from 5 August. So the fix is in trunk and not yet in anyone’s shop. If you have read what I wrote about milestone tags on the date filter fixes, you know how that can go: merged code can sit for a release line or two before it reaches production. A milestone is a plan, not a delivery date.

So the practical read is short: nothing to do except update when 6.7.14.0 arrives. The only thing worth watching is the gap. If you are launching a promotion campaign before that release lands, test the removal path on your own promotion first.

The part that generalises

The client asked for a landing page. The landing page needed a promotion. The promotion needed more than one discount line item. And a code path that had been quiet for a year started throwing.

That is the normal shape of core bugs in software this size. They need a specific configuration that most shops never build, and they wait.

If you are running a Shopware 6 shop on 6.7.2.0 or later, which by now means any shop that has kept up with major versions, and you are building a campaign around promotions, this is worth ten minutes of testing before the ad spend starts. Add the promotion, open the cart, remove a discount row, and watch what happens.

Get in touch if you want this kind of work done properly, or read through the case studies for more of it.

PR on GitHub: #19222. Contributor profile at github.com/zaifastafa.

Frequently asked questions

Which Shopware versions are affected? Shopware 6.7.2.0 through 6.7.13.0. The loop that causes it was introduced on 7 August 2025 by PR #11788 and first shipped in 6.7.2.0 on 1 September 2025. The 6.6 LTS line still uses the older implementation and does not have this specific bug. The fix was merged on 17 August 2026 and is tagged for 6.7.14.0, which had not been released at the time of writing.

How do I know if my shop is hitting this? Two conditions have to line up. First, a promotion in your shop has to produce two or more discount line items in the same cart, sharing one promotion ID. Second, a customer has to remove one of those discount rows. If your promotions each produce a single discount line item, which is the common case, you will never see it. That is why the bug survived a year in a release line.

What does the customer actually see? In the storefront, a generic error message and a discount that refuses to go away. The storefront controller catches the exception and shows the default error flash, so there is no stack trace and no hint about promotions. The exception escapes before the cart is recalculated and saved, so nothing is persisted and the cart reloads unchanged. Store API and headless callers get an HTTP 400 with CHECKOUT__LINE_ITEM_NOT_FOUND, which is at least readable.

What do I do if I am on an affected version? Update once 6.7.14.0 is out. The fix is already in trunk, so this resolves itself with the next patch release and needs nothing from you. The only thing to watch is the gap between now and that release. If you are launching a promotion campaign in that window, test the removal path first, or keep the campaign promotion to a single discount line item.

Is order or customer data at risk? No. The failure happens in the in-memory cart during a remove request, before the recalculation and the persist step. Nothing is written. The damage is a confusing checkout moment on a page you are paying to send traffic to, which for a campaign landing page is expensive enough on its own.

Share this article

Found this useful? Share it with your network

Huzaifa Mustafa

Huzaifa Mustafa

Shopware 6 certified developer with 166+ custom plugins delivered and 97+ clients across the DACH region. I write about Shopware architecture, e-commerce performance, and lessons from real projects.

Need help with Shopware?

Let's discuss how I can help with your e-commerce project.