Shopware-Promotion-Bug: lineItemNotFound beim Rabatt entfernen

Code-Diff des lineItemNotFound-Guards in Shopwares Schleife zum Entfernen von Promotion-Rabatten

Update, September 2026: Der Fix ist ausgeliefert. Shopware 6.7.14.0 ist am 9. September 2026 erschienen, mit PR #19222 darin. Shops zwischen 6.7.2.0 und 6.7.13.1 tragen den Bug weiterhin, und ein Update auf 6.7.14.0 ist die komplette Abhilfe. Der Rest des Beitrags erklärt, wie der Bug funktioniert hat und wie er entstanden ist.

Der Kunde benötigte eine Kampagnen-Landingpage, für die ich ein eigenes Plugin entwickelt habe. Auf der Landingpage trugen Kunden ihren Gutscheincode manuell in ein Eingabefeld ein (er wurde nicht automatisch hinzugefügt) und wählten die gewünschte Grösse des in den Plugin-Einstellungen konfigurierten Produkts aus. Erst mit der Grössenauswahl und dem Hinzufügen wurde der Artikel samt Rabatten in den Warenkorb gelegt.

Dieser Teil funktionierte beim ersten Build wie geplant.

Dann habe ich im Test den Warenkorb geöffnet, auf Entfernen bei einer der Rabattzeilen geklickt und eine generische Fehlermeldung bekommen. Der Rabatt blieb genau dort, wo er war.

Da ich das Plugin und die gesamte Funktion selbst entwickelt habe, habe ich natürlich zuerst dort angesetzt. Das Plugin war jedoch völlig in Ordnung: Die Ursache lag in Shopwares Core-Subscriber.

Die Schleife, die ihre eigene Liste auffrisst

Wird eine Promotion-Position entfernt, räumt Shopwares StorefrontCartSubscriber den Rest dieser Promotion auf. Jede weitere Rabattposition mit derselben promotionId fliegt ebenfalls raus, und jedes Entfernen feuert ein BeforeLineItemRemovedEvent.

So sah der ausgelieferte Code aus:

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

Lesen Sie die Dispatch-Zeile noch einmal. Der Subscriber, der diese Schleife ausführt, lauscht selbst auf BeforeLineItemRemovedEvent. Der Dispatch ruft die Methode also erneut auf, aus der Methode heraus.

Der verschachtelte Aufruf baut seine eigene Liste der Geschwisterpositionen, und zu diesem Zeitpunkt hat die äussere Schleife erst eine davon entfernt. Der verschachtelte Aufruf entfernt bereitwillig den Rest. Die Kontrolle kehrt zur äusseren Schleife zurück, die immer noch eine Collection durchläuft, die vor alledem eingefroren wurde. Sie erreicht die nächste Geschwisterposition und ruft $cart->remove() mit einer ID auf, die es nicht mehr gibt.

Cart::remove() hat dazu eine klare Meinung:

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

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

Zwei Geschwisterrabatte reichen aus. Einer reicht nicht, weil nach dem ersten Entfernen nichts mehr zu durchlaufen ist. Die meisten Promotions in den meisten Shops erzeugen genau eine Rabattposition. Deshalb sass der Bug ein Jahr lang in einer Release-Linie, ohne dass ihn jemand gemeldet hat.

Wie er dort hingekommen ist

Der interessante Teil ist die Historie.

Vor August 2025 nahm dieselbe Methode nur die erste Geschwisterposition und entfernte sie über den CartService, mit einem Kommentar, der die Rekursion als Absicht auswies:

// 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);

Ein Item, dann Rekursion, und die Rekursion endet, wenn der Filter leer zurückkommt. Liest sich eigenwillig, aber die beiden Hälften waren sich einig.

Dann hat PR #11788 den Subscriber umgebaut, damit aus einem Event-Listener heraus nicht mehr die komplette Remove-Route aufgerufen wird. Dieser Aufruf lief in einen Deadlock. Die Remove-Route sperrt den Warenkorb, und der Wiedereintritt aus ihrem eigenen Event heraus wartete auf eine Sperre, die der äussere Aufruf bereits hielt. Shops bekamen “Cart is locked due to concurrent write operation” statt eines entfernten Rabatts, ausgelöst von derselben Promotion mit zwei Rabatten. Für sich genommen eine gute Änderung. Sie ersetzte die Ein-Item-Rekursion durch ein foreach über die gesamte gefilterte Liste und liess den Event-Dispatch in der Schleife stehen.

Die Rekursion blieb also, und die Iteration über einen veralteten Snapshot kam obendrauf. Beide Mechanismen erledigen jetzt dieselbe Arbeit doppelt und sind sich uneinig darüber, was noch im Warenkorb liegt.

Diese Änderung ist am 1. September 2025 mit 6.7.2.0 ausgeliefert worden.

Was der Kunde gesehen hätte

Der Kunde sieht keinen Stacktrace, und das ist ein Grund, warum man den Fehler leicht übersieht.

CartLineItemController::deleteLineItem legt das Ganze in ein try { ... } catch (\Exception) und wirft eine generische Fehlermeldung aus. Der Kunde klickt auf Entfernen, bekommt “da ist etwas schiefgelaufen”, und der Rabatt liegt weiterhin im Warenkorb. Die Exception verlässt CartItemRemoveRoute::remove(), bevor calculate() und save() überhaupt laufen. Es wird nichts geschrieben, und der Warenkorb lädt exakt so neu, wie er war.

Store-API- und Headless-Clients bekommen einen sauberen HTTP 400 mit CHECKOUT__LINE_ITEM_NOT_FOUND. Denen wird immerhin gesagt, was passiert ist.

Auf einer Kampagnen-Landingpage, für die Sie Traffic einkaufen, ist “da ist etwas schiefgelaufen” im Warenkorb ein schlechter Ort für den Klick.

Der Fix

Sechs Zeilen, von denen eine die Arbeit macht:

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

Den Warenkorb fragen, ob die Position noch da ist, bevor sie entfernt wird. Wer zuerst bei einer Geschwisterposition ankommt, gewinnt, der andere überspringt sie, und das Event feuert einmal pro Position statt zweimal.

Der Test war interessanter als der Fix.

Alle anderen Tests in dieser Datei geben dem Subscriber einen gestubbten Dispatcher, entweder einen Collecting-Stub, der Events aufzeichnet, oder einen PHPUnit-Mock. Keiner von beiden liefert etwas aus. Gegen beide läuft der fehlerhafte Code grün durch. Der verschachtelte Aufruf passiert nie, also stolpert die äussere Schleife nie über ihre eigene Arbeit.

Der Regressionstest brauchte einen echten Symfony-EventDispatcher mit registriertem Subscriber, drei Geschwisterrabatte im Warenkorb und ein Entfernen über einen echten Dispatch. Nur in diesem Setup existiert der Bug überhaupt.

Das gehört ins Notizbuch: Ein Test-Double, das dispatch() schluckt, versteckt jeden Re-Entrancy-Bug im Code darunter, und zwar geräuschlos.

Wann der Fix ausgeliefert wurde

Eröffnet am 12. August 2026. Gemerged am 17. August 2026. Getaggt für Milestone 6.7.14.0. Ausgeliefert mit 6.7.14.0 am 9. September 2026.

Der Tag hat gehalten, und das ist nichts, was ich voraussetze. Bei den Datumsfilter-Fixes war der Quartals-Fix für 6.7.10.0 getaggt, verpasste dieses Release und brauchte knapp zwei Monate bis in einen Shop. Hier lagen zwischen Merge und Release drei Wochen, und der Fix landete genau in dem Milestone, für den er getaggt war. Ein Milestone bleibt ein Plan und kein Liefertermin. Dieser hier hat einfach gehalten.

Eine Sache zur Lücke dazwischen: Das Wartungsrelease 6.7.13.1 vom 25. August enthält den Fix nicht. Jeder 6.7-Shop von 6.7.2.0 bis einschliesslich 6.7.13.1 trägt den Bug weiterhin in sich.

Praktisch bleibt also wenig zu tun: auf 6.7.14.0 updaten. Wenn Sie noch nicht updaten können und in der Zwischenzeit eine Promotion-Kampagne starten, testen Sie vorher den Entfernen-Pfad mit Ihrer eigenen Promotion.

Was sich verallgemeinern lässt

Der Kunde wollte eine Landingpage. Die Landingpage brauchte eine Promotion. Die Promotion brauchte mehr als eine Rabattposition. Und ein Codepfad, der ein Jahr lang ruhig war, fing an zu werfen.

So sehen Core-Bugs in Software dieser Grösse normalerweise aus. Sie brauchen eine bestimmte Konfiguration, die die meisten Shops nie bauen, und sie warten.

Wenn Sie einen Shopware-6-Shop zwischen 6.7.2.0 und 6.7.13.1 betreiben, was inzwischen jeden Shop betrifft, der bei den Hauptversionen mitgegangen ist und 6.7.14.0 noch nicht eingespielt hat, und eine Kampagne rund um Promotions bauen, sind zehn Minuten Test vor dem ersten Werbebudget gut investiert. Promotion anlegen, Warenkorb öffnen, eine Rabattzeile entfernen, und schauen, was passiert.

Melden Sie sich, wenn Sie solche Arbeit sauber erledigt haben wollen, oder schauen Sie in die Case Studies.

PR auf GitHub: #19222, gemerged am 17. August 2026 und ausgeliefert in 6.7.14.0. Contributor-Profil unter github.com/zaifastafa.

Häufige Fragen

Welche Shopware-Versionen sind betroffen? Shopware 6.7.2.0 bis 6.7.13.1. Die verantwortliche Schleife kam am 7. August 2025 mit PR #11788 in den Core und ist am 1. September 2025 erstmals in 6.7.2.0 ausgeliefert worden. Die 6.6-LTS-Linie nutzt weiterhin die ältere Implementierung und hat diesen konkreten Bug nicht. Der Fix wurde am 17. August 2026 gemerged und ist am 9. September 2026 mit 6.7.14.0 ausgeliefert worden. Ein Update auf dieses Release erledigt die Sache.

Woher weiss ich, ob mein Shop betroffen ist? Zwei Bedingungen müssen zusammenkommen. Erstens muss eine Promotion in Ihrem Shop zwei oder mehr Rabattpositionen im selben Warenkorb erzeugen, die dieselbe Promotion-ID tragen. Zweitens muss ein Kunde eine dieser Rabattzeilen entfernen. Erzeugt jede Ihrer Promotions nur eine einzige Rabattposition, was der Normalfall ist, sehen Sie den Fehler nie. Genau deshalb hat der Bug ein Jahr in einer Release-Linie überlebt.

Was sieht der Kunde tatsächlich? Im Storefront eine generische Fehlermeldung und einen Rabatt, der nicht verschwindet. Der Storefront-Controller fängt die Exception ab und zeigt die Standard-Fehlermeldung, also gibt es keinen Stacktrace und keinen Hinweis auf Promotions. Die Exception verlässt den Request, bevor der Warenkorb neu berechnet und gespeichert wird, also wird nichts persistiert und der Warenkorb lädt unverändert neu. Store-API- und Headless-Clients bekommen einen HTTP 400 mit CHECKOUT__LINE_ITEM_NOT_FOUND, was immerhin lesbar ist.

Was mache ich, wenn ich auf einer betroffenen Version bin? Updaten Sie auf 6.7.14.0, veröffentlicht am 9. September 2026. Das ist der gesamte Fix, mehr braucht es nicht: keine Konfigurationsänderung, keine Migration. Wenn Sie nicht sofort updaten können und in der Zwischenzeit eine Promotion-Kampagne starten, testen Sie vorher den Entfernen-Pfad, oder halten Sie die Kampagnen-Promotion bei einer einzigen Rabattposition.

Sind Bestell- oder Kundendaten gefährdet? Nein. Der Fehler passiert im Warenkorb im Arbeitsspeicher während eines Remove-Requests, vor Neuberechnung und Persistierung. Es wird nichts geschrieben. Der Schaden ist ein irritierender Moment im Checkout auf einer Seite, für die Sie Traffic einkaufen. Bei einer Kampagnen-Landingpage ist das teuer genug.

Artikel teilen

Fanden Sie das hilfreich? Teilen Sie es mit Ihrem Netzwerk

Huzaifa Mustafa

Huzaifa Mustafa

Shopware 6 zertifizierter Entwickler mit 166+ individuellen Plugins und 97+ Kunden in der DACH-Region. Ich schreibe über Shopware-Architektur, E-Commerce-Performance und Erfahrungen aus realen Projekten.

Brauchen Sie Hilfe mit Shopware?

Lassen Sie uns besprechen, wie ich bei Ihrem E-Commerce-Projekt helfen kann.