#19155·Sylius

[CoreBundle] ShopBasedCartContext is missing the kernel.reset tag, cart leaks between requests in worker mode

Author: bricejuliaCreated Jul 27, 2026Updated Sep 16, 2026
LabelsPotential Bug

Sylius version(s) affected

2.2.7 (code unchanged on 2.2 / 2.x branches)

Description

ShopBasedCartContext memoizes the resolved cart in a private property and implements ResettableCartContextInterface (which extends Symfony\Contracts\Service\ResetInterface) precisely so it can be reset between requests. But the sylius.context.cart.new_shop_based service never receives the kernel.reset tag, so in a long-running runtime (FrankenPHP worker mode, RoadRunner…) the memoized cart leaks into every subsequent request handled by that worker.

$ bin/console debug:container sylius.context.cart.new

 // This service is a private alias for the service sylius.context.cart.new_shop_based

  Service ID   sylius.context.cart.new_shop_based
  Class        Sylius\Component\Core\Cart\Context\ShopBasedCartContext
  Tags         sylius.context.cart (priority: -999)
               container.decorator (id: sylius.context.cart.new, inner: sylius.context.cart.new_shop_based.inner)
  Shared       yes

No kernel.reset, and the service does not appear in debug:container --tag=kernel.reset.

Two mechanisms that should have tagged it both miss:

  1. Sylius\Bundle\OrderBundle\DependencyInjection\Compiler\TagResettableCartContextsPass iterates findTaggedServiceIds('sylius.context.cart') and tags the resettable ones. sylius.context.cart.new_shop_based is a decorator of sylius.context.cart.new and does not carry the sylius.context.cart tag itself — it only inherits it later, when DecoratorServicePass copies tags from the decorated definition onto the decorator. The pass is registered in SyliusOrderBundle::build() with the default PassConfig::TYPE_BEFORE_OPTIMIZATION, i.e. before DecoratorServicePass (optimization phase, PassConfig.php:69), so it never sees the service.
  2. Symfony's ResetInterface autoconfiguration (FrameworkExtension) does not apply either, because the definition comes from XML with autoconfigure=false.

Because getCart() returns the memoized cart at the very top of the method — before the block that assigns channel, currency, locale and customer — a cart memoized during a guest request stays createdByGuest = true with tokenValue = null for every later request in that worker, whoever is authenticated.

The most visible consequence is in ApiBundle: ApiCartBlamerListener::onLoginSuccess() receives that stale cart and dispatches new BlameCart($user->getEmail(), $cart->getTokenValue()) with null, which is a hard 500:

Sylius\Bundle\ApiBundle\Command\Cart\BlameCart::__construct(): Argument #2 ($orderTokenValue)
must be of type string, null given, called in src/Sylius/Bundle/ApiBundle/EventListener/ApiCartBlamerListener.php on line 52

Secondary issue, independent of the runtime: ApiCartBlamerListener has no null check on getTokenValue(), although Sylius itself acknowledges that carts with a null token value exist — PickupCartHandler backfills them (if (null === $activeCart->getTokenValue()) { $activeCart->setTokenValue(...) }). So any tokenless cart reaching that listener turns a login into a 500 instead of being skipped.

How to reproduce

Container-level (no runtime needed)

On a stock 2.2.7 install:

bash
bin/console debug:container sylius.context.cart.new          # tags: sylius.context.cart, container.decorator
bin/console debug:container --tag=kernel.reset | grep cart   # no match

ShopBasedCartContext implements ResettableCartContextInterface, so it is expected to be listed.

Runtime

Setup: Sylius 2.2.7 with ApiBundle, served by FrankenPHP in worker mode (symfony/runtime + runtime/frankenphp-symfony), dev env so the profiler is enabled.

  1. Send any shop request as a guest. In dev the profiler's sylius.collector.cart (CartCollector) calls sylius.context.cart->getCart() on every request; it falls through to ShopBasedCartContext, which creates a brand-new transient cart (tokenValue = null, createdByGuest = true, never persisted) and memoizes it. The profiler is only the most reliable trigger — any consumer of the cart context on a guest request does the same (e.g. sylius_adyen.controller.shop.express_checkout.*), which is why this is not a dev-only problem.
  2. On the same worker process, send POST /api/v2/shop/orders with a shop-user JWT, for a customer with no non-empty createdByGuest = false cart in that channel. LoginSuccessEvent fires (stateless firewall), the URI contains orders so the section is ShopApiOrdersSubSection, and ApiCartBlamerListener resolves the cart:
    • TokenValueBasedCartContextCartNotFoundException (no tokenValue request attribute on this route)
    • CustomerAndChannelBasedCartContextCartNotFoundException (no matching cart)
    • composite falls through to ShopBasedCartContext → returns the stale cart memoized in step 1
  3. Response is 500 with the BlameCart::__construct() TypeError above.

The same request in non-worker mode returns 201: a fresh ShopBasedCartContext runs its assignment block, setCustomerWithAuthorization() sets createdByGuest = false, and the listener returns early at !$cart->isCreatedByGuest().

More generally, and regardless of ApiBundle: in worker mode any two requests handled by the same worker share the cart resolved by the first one.

Possible Solution

1. Make the tag reach the decorator.

Simplest: tag it explicitly in src/Sylius/Bundle/CoreBundle/Resources/config/services/context.xml:

xml
<service id="sylius.context.cart.new_shop_based" class="Sylius\Component\Core\Cart\Context\ShopBasedCartContext"
         decorates="sylius.context.cart.new" decoration-priority="256">
    <argument type="service" id="sylius.context.cart.new_shop_based.inner" />
    <argument type="service" id="sylius.context.shopper" />
    <argument type="service" id="sylius.resolver.cart.created_by_guest_flag" />
    <tag name="kernel.reset" method="reset" />
</service>

Note that simply moving TagResettableCartContextsPass after DecoratorServicePass would not work: ResettableServicePass, which consumes kernel.reset and builds services_resetter, is registered at TYPE_BEFORE_OPTIMIZATION priority -32 (FrameworkBundle.php:195), so it also runs before DecoratorServicePass. If you prefer to keep the tagging automatic, the pass would have to resolve decorators itself, e.g. also inspect definitions whose getDecoratedService() points at a sylius.context.cart-tagged id.

Application-side workaround in the meantime — redeclare the decorator with the tag:

yaml
services:
    sylius.context.cart.new_shop_based:
        class: Sylius\Component\Core\Cart\Context\ShopBasedCartContext
        decorates: sylius.context.cart.new
        decoration_priority: 256
        arguments:
            - '@.inner'
            - '@sylius.context.shopper'
            - '@sylius.resolver.cart.created_by_guest_flag'
        tags:
            - { name: kernel.reset, method: reset }

2. Guard ApiCartBlamerListener against a tokenless cart, so it degrades gracefully instead of returning a 500 during login:

php
$tokenValue = $cart->getTokenValue();
if (null === $tokenValue) {
    return;
}

$this->commandBus->dispatch(new BlameCart($user->getEmail(), $tokenValue));

BlameCartHandler looks the cart up with findCartByTokenValue(), so a cart without a token value is not blameable anyway.

I'm happy to open a PR for either or both if you agree with the direction.

Additional Context

No response