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 wird
Eröffnet am 12. August 2026. Gemerged am 17. August 2026. Getaggt für Milestone 6.7.14.0.
Das aktuellste Release ist derzeit 6.7.13.0 vom 5. August. Der Fix liegt also im Trunk und noch in keinem Shop. Wer gelesen hat, was ich zu Milestone-Tags bei den Datumsfilter-Fixes geschrieben habe, kennt den Ablauf: Gemergter Code kann ein bis zwei Release-Linien liegen, bevor er in Produktion ankommt. Ein Milestone ist ein Plan, kein Liefertermin.
Praktisch bleibt also wenig zu tun: updaten, wenn 6.7.14.0 kommt. Beobachtenswert ist nur die Lücke. Wenn Sie vor diesem Release 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 ab 6.7.2.0 betreiben, was inzwischen jeden Shop betrifft, der bei den Hauptversionen mitgegangen ist, 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. Contributor-Profil unter github.com/zaifastafa.
Häufige Fragen
Welche Shopware-Versionen sind betroffen? Shopware 6.7.2.0 bis 6.7.13.0. 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 für 6.7.14.0 getaggt, das zum Zeitpunkt dieses Beitrags noch nicht veröffentlicht war.
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, sobald 6.7.14.0 draussen ist. Der Fix liegt bereits im Trunk, das erledigt sich also mit dem nächsten Patch-Release von allein und verlangt nichts von Ihnen. Zu beobachten ist nur die Lücke bis dahin. Wenn Sie in diesem Zeitfenster 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.