smartlabs/sonata-ecommerce

Ecommerce solution for Symfony and Sonata Admin (products, orders, payments, invoices, returns, refunds)

Maintainers

Package info

github.com/smartlabsAT/sonata-project-ecommerce

Homepage

Type:symfony-bundle

pkg:composer/smartlabs/sonata-ecommerce

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 41

4.4.0 2026-05-07 22:02 UTC

This package is auto-updated.

Last update: 2026-05-07 22:36:26 UTC


README

Latest Stable Version License

eCommerce solution for Symfony, built on Sonata Admin. Products, categories, cart, checkout, orders, invoices, payments, and delivery — all integrated with the Sonata ecosystem.

Table of Contents

Background

This bundle is a maintained port of the archived sonata-project/ecommerce (v3.5.2, July 2022).

We have been using sonata-project/ecommerce in production for years. When it was archived and left behind by the Symfony ecosystem, we decided to port it to current versions and share it with the community. The functionality and architecture are preserved 1:1 — only changes required for compatibility with current dependency versions have been made.

Requirements

  • PHP ^8.2
  • Symfony 7.4.*
  • Sonata Admin ^4.42
  • Doctrine ORM ^3.6 / DBAL ^4.0

Installation

composer require smartlabs/sonata-ecommerce

Register the bundles in config/bundles.php:

return [
    // ... other bundles
    Sonata\ProductBundle\SonataProductBundle::class => ['all' => true],
    Sonata\BasketBundle\SonataBasketBundle::class => ['all' => true],
    Sonata\OrderBundle\SonataOrderBundle::class => ['all' => true],
    Sonata\InvoiceBundle\SonataInvoiceBundle::class => ['all' => true],
    Sonata\CustomerBundle\SonataCustomerBundle::class => ['all' => true],
    Sonata\PaymentBundle\SonataPaymentBundle::class => ['all' => true],
    Sonata\DeliveryBundle\SonataDeliveryBundle::class => ['all' => true],
    Sonata\PriceBundle\SonataPriceBundle::class => ['all' => true],
    Sonata\DiscountBundle\SonataDiscountBundle::class => ['all' => true],
    Sonata\ReturnBundle\SonataReturnBundle::class => ['all' => true],
];

Quick Start

1. Create your entities

The bundle provides abstract Base* entities. Create concrete entities in your application:

// src/Entity/Commerce/Product.php
namespace App\Entity\Commerce;

use Doctrine\ORM\Mapping as ORM;
use Sonata\ProductBundle\Entity\BaseProduct;

#[ORM\Entity]
#[ORM\Table(name: 'commerce__product')]
class Product extends BaseProduct
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    protected ?int $id = null;
}

Repeat for Basket, BasketElement, Order, OrderElement, Invoice, InvoiceElement, Customer, Address, Transaction, Delivery, Package, ProductCategory, ProductCollection.

2. Configure the bundles

Create configuration files under config/packages/ for each sub-bundle (sonata_product.yaml, sonata_basket.yaml, etc.) to map your entity classes.

3. Create the database schema

php bin/console doctrine:schema:update --force

Architecture

Sub-Bundles

Bundle Namespace Purpose
SonataProductBundle Sonata\ProductBundle Products, variants, categories, collections
SonataBasketBundle Sonata\BasketBundle Shopping cart
SonataOrderBundle Sonata\OrderBundle Orders, checkout, pending-order lifecycle
SonataInvoiceBundle Sonata\InvoiceBundle Invoices, credit notes
SonataCustomerBundle Sonata\CustomerBundle Customers, addresses
SonataPaymentBundle Sonata\PaymentBundle Payments, transactions, payment splits
SonataDeliveryBundle Sonata\DeliveryBundle Delivery methods, free-shipping threshold
SonataPriceBundle Sonata\PriceBundle Price calculation
SonataDiscountBundle Sonata\DiscountBundle Discount rules, coupon codes, vouchers, balance ledger
SonataReturnBundle Sonata\ReturnBundle Returns, refunds, refund allocator

Shared Component

Sonata\Component contains shared code used across all bundles: interfaces, events, currency handling, payment implementations (PayPal, Ogone, Stripe, Check, Pass, Debug), transformers, and the base entity manager.

Differences from the Original

This bundle is a 1:1 port of the original sonata-project/ecommerce. Only changes required for compatibility with current dependency versions have been made.

Serializer: Symfony Serializer instead of JMS Serializer

The original bundle used JMS Serializer handlers (via Sonata\Form\Serializer\BaseSerializerHandler) to serialize entities to/from their IDs. This base class was deprecated in sonata-project/form-extensions 1.13 and removed in 2.0.

Since sonata-project/form-extensions 2.x no longer provides BaseSerializerHandler and JMS Serializer is not a required dependency, the serializer handlers have been reimplemented as Symfony Serializer normalizers:

Original (JMS Serializer) Ported (Symfony Serializer)
Sonata\Form\Serializer\BaseSerializerHandler Component\Serializer\BaseSerializerNormalizer
BasketBundle\Serializer\BasketSerializerHandler BasketBundle\Serializer\BasketSerializerNormalizer
CustomerBundle\Serializer\CustomerSerializerHandler CustomerBundle\Serializer\CustomerSerializerNormalizer
OrderBundle\Serializer\OrderSerializerHandler OrderBundle\Serializer\OrderSerializerNormalizer
OrderBundle\Serializer\OrderElementSerializerHandler OrderBundle\Serializer\OrderElementSerializerNormalizer
InvoiceBundle\Serializer\InvoiceSerializerHandler InvoiceBundle\Serializer\InvoiceSerializerNormalizer
ProductBundle\Serializer\ProductSerializerHandler ProductBundle\Serializer\ProductSerializerNormalizer

The functionality is identical: entities are serialized to their ID and deserialized back to entity objects via the corresponding manager service.

Service tag: serializer.normalizer (replaces jms_serializer.subscribing_handler)

Doctrine DBAL: Direct Connection Access via EntityManager

The original bundle's manager classes (extending Sonata\Doctrine\Entity\BaseEntityManager) used $this->getConnection() to access the DBAL connection for raw SQL queries.

Since the port uses its own AbstractEntityManager (which does not expose a getConnection() method), affected classes use $this->em->getConnection() instead. This accesses the Doctrine DBAL connection through the injected EntityManagerInterface.

Affected class: ProductBundle\Manager\ProductCategoryManager::getProductCount()

The functionality is identical — only the access path to the DBAL connection differs. The raw SQL query itself has been updated for DBAL 4.x compatibility:

  • $stmt->execute() / $stmt->fetchAll()$connection->executeQuery() / ->fetchAllAssociative()
  • $metadata->table['name']$metadata->getTableName()

Message Queue: Symfony Messenger instead of SonataNotificationBundle

The original bundle used sonata-project/notification-bundle for async order processing after payment (stock updates). SonataNotificationBundle is archived and not compatible with Symfony 7.x.

The consumers have been reimplemented as Symfony Messenger handlers:

Original (SonataNotificationBundle) Ported (Symfony Messenger)
PaymentBundle\Consumer\PaymentProcessOrderConsumer PaymentBundle\MessageHandler\ProcessOrderHandler
PaymentBundle\Consumer\PaymentProcessOrderElementConsumer PaymentBundle\MessageHandler\ProcessOrderElementHandler
sonata_payment_order_process notification type PaymentBundle\Message\ProcessOrderMessage
sonata_payment_order_element_process notification type PaymentBundle\Message\ProcessOrderElementMessage
BackendInterface::createAndPublish() in PaymentHandler MessageBusInterface::dispatch() in PaymentHandler

The business logic is identical: after payment processing, a ProcessOrderMessage is dispatched. The handler loads the order and transaction, then dispatches a ProcessOrderElementMessage for each order element. The element handler decrements product stock when both transaction and order status are VALIDATED.

Optional dependency: symfony/messenger is listed as a suggest dependency. When not installed, the bundle works normally — only async stock updates after payment are disabled. Install it with:

composer require symfony/messenger

To process messages asynchronously, configure a transport in config/packages/messenger.yaml:

framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            'Sonata\PaymentBundle\Message\ProcessOrderMessage': async
            'Sonata\PaymentBundle\Message\ProcessOrderElementMessage': async

REST API Security

The bundle includes optional REST API controllers (via FOSRestBundle) for Products, Baskets, Customers, Addresses, Orders, and Invoices. These controllers do not include built-in authentication or authorization — this is by design, matching the original bundle architecture.

You must secure the API routes via your Symfony firewall. If you import the API routes, add appropriate access control rules:

# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            # Configure your auth strategy: JWT, API key, OAuth, etc.
    access_control:
        - { path: ^/api/ecommerce, roles: ROLE_API }

Without firewall configuration, all API CRUD endpoints are publicly accessible. Only import the API routes if you need them, and always secure them at the firewall level.

Catalog

The SonataProductBundle provides the product catalog: products with optional variants, hierarchical categories, and flat collection tags. Products are pluggable through a provider system — each product type has its own provider that knows how to add the product to a basket, calculate its price, build admin forms, and create variations.

Configuration

# config/packages/sonata_product.yaml
sonata_product:
    class:
        product:            App\Entity\Commerce\Product
        product_category:   App\Entity\Commerce\ProductCategory
        product_collection: App\Entity\Commerce\ProductCollection
        category:           App\Entity\Commerce\Category
        collection:         App\Entity\Commerce\Collection
        package:            App\Entity\Commerce\Package
        delivery:           App\Entity\Commerce\Delivery
        media:              App\Entity\Commerce\Media
        gallery:            App\Entity\Commerce\Gallery
    products:
        # the key (`my_product`) is the product code used by the pool to dispatch by type
        my_product:
            provider: App\Product\MyProductProvider          # FQCN of a class implementing ProductProviderInterface
            manager:  Sonata\ProductBundle\Manager\ProductManager
            variations:
                fields: [color, size]

Each entry under products: registers a product type. Both provider and manager are service ids — pass either an FQCN (recommended, autoconfigured by Symfony) or a string id you registered yourself. The provider service must implement ProductProviderInterface; the manager must implement ProductManagerInterface. variations.fields lists which entity fields differ between a master product and its variations (e.g. color, size for clothing; empty for products without variants).

Entities

Entity Interface Base class
Product ProductInterface BaseProduct
ProductCategory ProductCategoryInterface BaseProductCategory
ProductCollection ProductCollectionInterface BaseProductCollection
Delivery DeliveryInterface BaseDelivery
Package PackageInterface BasePackage

Categories form a hierarchy (one parent, many children). Collections are flat tags — a product can belong to many collections regardless of its category.

Product providers

ProductProviderInterface is the public extension point. The default DefaultProductProvider covers simple products; for product types that need custom basket logic (configurable products, subscriptions, gift cards), implement your own provider. Key responsibilities:

  • defineAddBasketForm(...) — the form a customer sees on the product page.
  • basketAddProduct(...) / basketMergeProduct(...) — what happens when a product (or quantity) is added to an existing basket.
  • calculatePrice(...) — price for a given quantity / currency / VAT mode.
  • isAddableToBasket(...) — gate against state-dependent rules (e.g. stock, subscription conflict).
  • createVariation(...) / synchronizeVariations(...) — variant management.
  • buildForm(...) / buildEditForm(...) / buildCreateForm(...) — admin form layout.

Register the provider as a service and reference it from sonata_product.products.<code>.provider.

Pool

Sonata\ProductBundle\Service\ProductPool is the registry that knows every registered product type:

$pool->getProduct(string $code): ProductDefinition;
$pool->getProvider(ProductInterface|string): ProductProviderInterface;
$pool->getManager(ProductInterface|string): ProductManagerInterface;
$pool->getProductCode(ProductInterface): ?string;
$pool->hasProvider(string $code): bool;
$pool->hasProduct(string $code): bool;

Inject the pool wherever you need to dispatch by product type. Every entry under sonata_product.products is wired into the pool at compile time.

Categories and collections

ProductCategoryManager and ProductCollectionManager provide CRUD plus a getProductCount() helper for category navigation pages. The category and collection hierarchies integrate with SonataClassificationBundle via the linking tables — see your ProductCategory entity's link to a Category entity.

Finder

ProductFinderInterface provides cross-selling and up-selling product recommendations:

$finder->getCrossSellingSimilarProducts(ProductInterface $product): array;
$finder->getCrossSellingSimilarParentProducts(ProductInterface $product, ?int $limit = null): array;
$finder->getUpSellingSimilarProducts(ProductInterface $product): array;

The default Sonata\Component\Product\ProductFinder is a thin Doctrine wrapper that uses the product's category and collection links to find related products. Override the service id sonata.product.finder if you need a smarter recommendation engine (collaborative filtering, view-history-based, etc.).

Generator command

sonata:product:generate <productCode> scaffolds the boilerplate files for a new product type (provider class, manager class, service registration). Useful when introducing a new product behaviour to the catalog.

Basket

The SonataBasketBundle provides the shopping cart: in-session storage, validation, and the transformation pipeline that converts a basket into a pending Order at sendbank time.

Configuration

# config/packages/sonata_basket.yaml
sonata_basket:
    class:
        basket:         App\Entity\Commerce\Basket
        basket_element: App\Entity\Commerce\BasketElement
        customer:       App\Entity\Commerce\Customer
    builder: sonata.basket.builder.standard
    factory: sonata.basket.session.factory
    loader:  sonata.basket.loader.standard

Customise builder / factory / loader only when you need session-storage alternatives (Redis, encrypted) or different lifecycle rules (e.g. multi-basket per session).

Entity model

Entity Interface Base class (Doctrine MappedSuperclass)
Basket BasketInterface BaseBasket
BasketElement BasketElementInterface BaseBasketElement

BaseBasket is in Sonata\BasketBundle\Entity\; the in-memory runtime class Sonata\Component\Basket\Basket is what the bundle hydrates inside a request and is rarely subclassed directly — extend BaseBasket for your persistable entity.

A Basket aggregates BasketElements plus session-derived state: customer, currency, delivery_method, delivery_address_id, payment_method. Each element references a Product by id and carries its own quantity, price, vat_rate. The options array on the basket is a free-form JSON-ish bag — the bundle uses it to thread the pending-order id (pending_order_id) across re-submits.

Public API

$basket->setCustomer(?CustomerInterface $customer): static;
$basket->getElement(ProductInterface $product): ?BasketElementInterface;
$basket->getElementByPos(int $pos): ?BasketElementInterface;
$basket->getBasketElements(): iterable;
$basket->getTotal(bool $includingVat = false, ?bool $recurrentOnly = null): string;
$basket->getDeliveryMethod(): ?ServiceDeliveryInterface;
$basket->getDeliveryPrice(bool $includingVat = false): string|int;
$basket->getDeliveryVat(): string|int;
$basket->getDeliveryAddress(): ?AddressInterface;
$basket->getCurrency(): ?CurrencyInterface;
$basket->buildPrices(): void;
$basket->reset(bool $full = true): void;

reset() empties the basket. Pass false to keep customer + delivery defaults but drop product elements.

Loading and saving

The session-bound Loader instance is what controllers should depend on — it lazily hydrates the Basket from the session, materialises its product references, and saves on shutdown:

$basket = $loader->getBasket();   // hydrated, ready to read or mutate

When a basket is mutated (product added, quantity changed, customer set), call $basket->buildPrices() and let the manager persist ($basketManager->save($basket)). The BasketBuilder interface is the canonical lifecycle hook; replace the default sonata.basket.builder.standard if your shop needs custom price-rebuild logic.

Events

Constant Channel Fires when
BasketEvents::PRE_ADD_PRODUCT sonata.ecommerce.basket.pre_add_product Before a product is added to the basket.
BasketEvents::POST_ADD_PRODUCT sonata.ecommerce.basket.post_add_product After a product is added.
BasketEvents::PRE_MERGE_PRODUCT sonata.ecommerce.basket.pre_merge_product Before merging quantity into an existing element.
BasketEvents::POST_MERGE_PRODUCT sonata.ecommerce.basket.post_merge_product After merging.
BasketEvents::PRE_CALCULATE_PRICE sonata.ecommerce.basket.pre_calculate_price Before price recomputation.
BasketEvents::POST_CALCULATE_PRICE sonata.ecommerce.basket.post_calculate_price After price recomputation.
BasketEvents::PRE_RESET sonata.ecommerce.basket.pre_reset Before the basket is cleared.

PRE_RESET is the hook the bundle uses to cancel any linked pending order in lockstep when the customer empties their basket. Listen to it if you need to clean up basket-derived side state (analytics breadcrumbs, abandoned-basket emails).

Transformation to Order

BasketTransformer converts a Basket into a pending Order at sendbank time. It is invoked by PaymentHandler::sendbank(), copies basket elements into order elements, calculates totals, and dispatches:

Constant Channel
TransformerEvents::PRE_BASKET_TO_ORDER_TRANSFORM sonata.ecommerce.pre_basket_to_order_transform
TransformerEvents::POST_BASKET_TO_ORDER_TRANSFORM sonata.ecommerce.post_basket_to_order_transform

You usually do not call the transformer directly — PaymentHandler orchestrates the full pipeline. Listen to the post-event if you need to enrich the Order with state derived from the basket (custom fields, gift wrapping flags).

Re-snapshotting an existing pending order

If the customer modifies the basket after sendbank but before payment confirms, PendingOrderReSnapshotService::reSnapshot(Order, Basket) re-syncs the Order's elements and totals in place — the same Order id is reused so the customer keeps a stable order reference. See Orders & Checkout for the full lifecycle.

Customers

The SonataCustomerBundle handles customer profiles and addresses. Customers can be authenticated (linked to your app's User) or guests (token-based, no login).

Configuration

# config/packages/sonata_customer.yaml
sonata_customer:
    class:
        customer:          App\Entity\Commerce\Customer
        customer_selector: Sonata\Component\Customer\CustomerSelector
        address:           App\Entity\Commerce\Address
        order:             App\Entity\Commerce\Order
        user:              App\Entity\User       # your app's User class
        user_identifier:   id                    # property used as the customer-to-user link
    profile:
        template:     '@SonataCustomer/Profile/action.html.twig'
        menu_builder: sonata.customer.profile.menu_builder.default
        # blocks + menu support customisable customer-area dashboards

Entities

Entity Interface Base class
Customer CustomerInterface BaseCustomer
Address AddressInterface BaseAddress

Address types — Component\Interfaces\Customer\AddressInterface:

Constant Value Purpose
TYPE_BILLING 1 Billing address (invoice destination).
TYPE_DELIVERY 2 Shipping address.
TYPE_CONTACT 3 Contact-only (no billing or shipping).

Each Customer has an addresses collection. The CustomerSelector picks an Address by type — typically returns the customer's default address for the requested type.

Guest customers

Set sonata_order.guest_checkout: true to allow checkouts without registration. Guest customers are persisted with a generated reference and an accessToken (cryptographic; passed via /shop/order/lookup and the return-flow URLs). The GuestOrderLookupType form is the reusable primitive for "look up my order" pages — see also Returns & Refunds for the same token used in the return flow.

Customer-to-Order linking

Orders carry a nullable customer_id so guests can submit orders without a Customer row. After a guest order is paid, you can promote the guest Customer to a registered User via your app's user-creation flow; the customer.user property links the two.

Pricing & Currency

Prices are stored as strings to preserve decimal precision; arithmetic flows through bcmath (scale 4 in most places).

Configuration

# config/packages/sonata_price.yaml
sonata_price:
    currency: EUR        # default currency code (ISO 4217)
    precision: 3         # decimal precision for display formatting

Currency

Component\Currency\Currency represents a single currency; CurrencyManager is the registry:

$currency = $currencyManager->findOneByLabel('EUR');
$currencies = $currencyManager->findBy(['enabled' => true]);

The Doctrine CurrencyDoctrineType persists Currency objects as their 3-letter code. Use CurrencyFormType to render currency dropdowns; CurrencyDataTransformer maps between code and Currency object. CurrencyDetector picks the active currency for a request — typical implementations sniff the customer's billing-address country, the session, or a query parameter.

Price calculation

Prices live on BasketElement (computed from the product) and on OrderElement (snapshotted at sendbank time). ProductProviderInterface::calculatePrice() is the canonical entry point — pass it a product, currency, VAT mode, and quantity to get back a price string. VAT is configurable on the product (vat_rate percentage); getTotal(includingVat: true|false) exists on baskets, basket elements, orders, and order elements to render either side of the VAT split.

Order totals

OrderTotalsCalculator (in SonataOrderBundle) recomputes order-level totals from the underlying elements + delivery + discounts. It is invoked automatically by BasketTransformer and PendingOrderReSnapshotService — call it directly only when you mutate an Order's elements or discounts after sendbank:

$calculator->recalculate($order);
$entityManager->flush();

Payments

The SonataPaymentBundle provides the payment provider abstraction: a registry (PaymentPool) that holds every configured provider, a selector that picks one based on the basket, and a controller stack that dispatches sendbank / callback / confirmation. PayPal, Ogone, Stripe, Check, Pass, and Debug providers ship with the bundle; custom providers extend BasePayment.

Configuration

# config/packages/sonata_payment.yaml
sonata_payment:
    selector: sonata.payment.selector.simple
    generator: sonata.payment.generator.mysql
    callback_base_uri: 'https://your-shop.com'   # In containerised setups set to the internal nginx address
    transformers:
        order:  sonata.payment.transformer.order
        basket: sonata.payment.transformer.basket
    services:
        # one block per provider you want enabled — see Stripe section below for a worked example
        stripe: ...
        paypal: ...
        debug: ...
        check: ...
        pass: ...
    methods:
        # method-code → provider-service-id (or null when the code matches the provider's own code)
        stripe: ~
        paypal: ~

callback_base_uri is the base URI used to build server-to-server payment callbacks. In containerised deployments (gateway → nginx → app), set this to the internal address so the gateway can reach /payment/<provider>/callback without bouncing through the public hostname.

Provider abstraction

Component\Interfaces\Payment\PaymentInterface is the contract every provider implements:

public function getName(): string;
public function getCode(): ?string;
public function sendbank(OrderInterface $order): Response;
public function callback(TransactionInterface $transaction): Response;
public function isCallbackValid(TransactionInterface $transaction): bool;
public function handleError(TransactionInterface $transaction): Response;
public function sendConfirmationReceipt(TransactionInterface $transaction): Response|false;
public function isRequestValid(TransactionInterface $transaction): bool;
public function isBasketValid(BasketInterface $basket): bool;
public function isAddableProduct(BasketInterface $basket, ProductInterface $product): bool;
public function applyTransactionId(TransactionInterface $transaction): void;
public function getOrderReference(TransactionInterface $transaction): string;
public function getTransformer(string $name): mixed;

Sonata\Component\Payment\BasePayment provides the common scaffolding; subclass it for new providers. Implementations that support refunds also implement RefundablePaymentInterface:

public function refund(TransactionInterface $transaction, ?string $amount = null, ?string $idempotencyKey = null): TransactionInterface;

StripePayment is the only shipped provider with refund support today. Refund orchestration lives in RefundProcessor — see Returns & Refunds and Vouchers.

Pool and selector

Sonata\PaymentBundle\Service\PaymentPool is the registry:

$pool->addMethod(PaymentInterface $instance);
$pool->getMethods(): array;          // [code => PaymentInterface]
$pool->getMethod(string $code): ?PaymentInterface;
$pool->findByClass(string $class): ?PaymentInterface;

PaymentSelectorInterface decides which providers are usable for a given basket:

$methods = $selector->getAvailableMethods(?BasketInterface $basket = null, ?AddressInterface $billingAddress = null);
$payment = $selector->getPayment(string $bank);

The default simple selector requires a billing address: with one set it returns every configured payment method, without one it returns false. Replace it (sonata_payment.selector) when your shop has finer-grained eligibility rules (geographic restrictions, B2B-only providers, basket-content checks, etc.).

Transactions

TransactionInterface is the per-payment audit record. Status constants (Component\Interfaces\Payment\TransactionInterface):

Constant Value Meaning
STATUS_ORDER_UNKNOWN -1 Order reference invalid.
STATUS_OPEN 0 Pending.
STATUS_PENDING 1 Submitted, waiting on async confirmation.
STATUS_VALIDATED 2 Payment confirmed.
STATUS_CANCELLED 3 Cancelled (sync).
STATUS_UNKNOWN 4 Unknown — non-standard provider response.
STATUS_REFUNDED 5 Fully refunded.
STATUS_PARTIALLY_REFUNDED 6 Partial refund recorded.
STATUS_ERROR_VALIDATION 9 Validation error.
STATUS_WRONG_CALLBACK 10 Callback signature failed.
STATUS_WRONG_REQUEST 11 Malformed request.
STATUS_ORDER_NOT_OPEN 12 Callback received against a non-OPEN order.

Events

Constant Channel Fires when
PaymentEvents::PRE_SENDBANK sonata.ecommerce.payment.pre_sendbank Before redirecting to a gateway.
PaymentEvents::POST_SENDBANK sonata.ecommerce.payment.post_sendbank After the gateway redirect response is built.
PaymentEvents::PRE_CALLBACK sonata.ecommerce.payment.pre_callback Before processing a gateway callback.
PaymentEvents::POST_CALLBACK sonata.ecommerce.payment.post_callback After processing a gateway callback.
PaymentEvents::CONFIRMATION sonata.ecommerce.payment.confirmation Payment succeeded — the canonical "transaction validated" hook.
PaymentEvents::REFUND_ASYNC_RESOLVED sonata.ecommerce.payment.refund_async_resolved Async refund (SEPA/Klarna) settles.
PaymentEvents::PRE_ERROR / POST_ERROR sonata.ecommerce.payment.{pre,post}_error Payment failure flow.

CONFIRMATION is the hook CouponConsumptionListener listens to so that RESERVED coupon codes flip to CONSUMED only after payment actually validates.

Adding a custom provider

Subclass Sonata\Component\Payment\BasePayment, implement the methods above (and RefundablePaymentInterface if you want refund support), register the service, and add an entry under sonata_payment.services + sonata_payment.methods. The Debug provider ships as a working reference for sandboxed development.

Payment splits

Order.payment_splits is a JSON column populated by Sonata\DiscountBundle\EventListener\PaymentSplitRecorder on OrderEvents::ORDER_PAID (priority 100). Subscribers reading the column MUST register at priority < 100. The shape is [{method, amount_inc, provider_ref?, coupon_code_id?}, ...]Sonata\Component\Payment\PaymentSplitMethod exposes the canonical method names (card, voucher). The recorder is idempotent: it short-circuits when payment_splits is already populated, so replays / manual re-dispatch cannot overwrite the snapshot.

Stripe Payment

Stripe is available as an optional payment provider with two modes.

Installation

composer require stripe/stripe-php

Configuration

sonata_payment:
    services:
        stripe:
            name: Stripe
            code: stripe
            options:
                mode: checkout  # checkout (redirect to Stripe) | embedded (on-page payment form)
                secret_key: '%env(STRIPE_SECRET_KEY)%'
                publishable_key: '%env(STRIPE_PUBLISHABLE_KEY)%'
                webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%'
                shop_secret_key: '%env(STRIPE_SHOP_SECRET_KEY)%'
    methods:
        stripe: ~

Mode: Checkout (default)

Customer is redirected to Stripe's hosted payment page. Simple, secure, zero PCI scope.

  • Supports all Stripe payment methods (card, SEPA, Klarna, etc.)
  • Stripe handles 3D Secure authentication
  • No frontend JS integration needed

Mode: Embedded (Payment Intents + Elements)

Payment form embedded directly on your checkout page using stripe.js + Stripe Elements.

  • Requires publishable_key in config
  • Load https://js.stripe.com/v3/ in your template (Stripe CDN, PCI requirement)
  • Call $stripePayment->getPaymentIntentClientSecret($order) in your controller
  • Pass client_secret to your Twig template
  • See Payment/stripe_embedded.html.twig for reference implementation

Example: One-Step-Checkout Integration

$payment = $this->paymentPool->getMethod('stripe');
if (!$payment instanceof StripePayment) {
    throw new \RuntimeException('Stripe payment method not configured');
}
$clientSecret = $payment->getPaymentIntentClientSecret($order);

return $this->render('shop/checkout.html.twig', [
    'stripe_client_secret' => $clientSecret,
    'stripe_publishable_key' => $payment->getOption('publishable_key'),
]);

Webhook Setup

The bundle ships the controller route at /stripe/webhook (route name sonata_payment_stripe_webhook, POST only). When you import @SonataPaymentBundle/Resources/config/routing/payment.php you can add a prefix: to namespace it under your shop URL — e.g. prefix: /payment produces /payment/stripe/webhook.

  1. Stripe Dashboard > Developers > Webhooks.
  2. Add endpoint URL: https://your-domain.com/<your-prefix>/stripe/webhook.
  3. Subscribe to events:
    • checkout.session.completed
    • checkout.session.async_payment_succeeded
    • checkout.session.async_payment_failed
    • checkout.session.expired
    • payment_intent.succeeded
    • payment_intent.payment_failed
    • charge.refunded — full-refund unwind handler.
    • charge.refund.updated — async-pending refunds (SEPA, Klarna) settling.
  4. Copy the signing secret to STRIPE_WEBHOOK_SECRET.
  5. Local development: stripe listen --forward-to localhost:8000/<your-prefix>/stripe/webhook.

Test vs Live Mode

  • Test: sk_test_... / pk_test_... — no real charges
  • Live: sk_live_... / pk_live_... — real money
  • Test card: 4242 4242 4242 4242, any future expiry, any CVC

Delivery

The SonataDeliveryBundle provides the delivery method abstraction: a registry (DeliveryPool) of shipping carriers, a selector that filters by basket / address eligibility, and a Package factory for fulfilment. Free address-required and address-not-required couriers are pre-registered as zero-cost defaults; real carriers extend BaseServiceDelivery.

Configuration

# config/packages/sonata_delivery.yaml
sonata_delivery:
    selector: sonata.delivery.selector.default
    free_shipping_threshold: '150.00'        # null = disabled (default)
    services:
        free_address_required:
            name: free_address_required
            code: free_address_required
            priority: 10
        free_address_not_required:
            name: free_address_not_required
            code: free_address_not_required
            priority: 10
    methods:
        # method-code → service-id (or null when the code matches a pre-registered service)
        free_address_required: ~
        free_address_not_required: ~

Add new carriers under services: with the corresponding entry under methods:, or register a Symfony service that implements ServiceDeliveryInterface and reference its id.

ServiceDelivery contract

ServiceDeliveryInterface (in Component\Interfaces\Delivery):

public function getCode(): string;
public function getName(): string;
public function getEnabled(): bool;
public function getPrice(): string;
public function getVatRate(): string;
public function isAddressRequired(): bool;
public function getTotal(BasketInterface $basket, bool $vat = false): string;
public function getVatAmount(BasketInterface $basket): string;
public function getPriority(): int;
public function setFreeShippingThreshold(?string $threshold): static;

isAddressRequired() distinguishes shipped delivery (yes) from take-away / digital (no). Priority orders the choices on the customer-facing checkout — lowest first.

Pool and selector

$pool->addMethod(ServiceDeliveryInterface $instance);
$pool->getMethods(): array;
$pool->getMethod(string $code): ?ServiceDeliveryInterface;
$pool->setFreeShippingThreshold(?string $threshold): void;

Component\Delivery\Selector filters by basket eligibility:

$methods = $selector->getAvailableMethods(?BasketInterface $basket = null, ?AddressInterface $deliveryAddress = null);

The selector also raises UndeliverableCountryException when no method covers the customer's address — catch it in your checkout controller to render a "we can't ship there" page.

Free Shipping Threshold

Set a basket total threshold above which shipping is free:

# config/packages/sonata_delivery.yaml
sonata_delivery:
    free_shipping_threshold: '150.00'   # null = disabled (default)

When the basket goods total (incl. VAT) meets or exceeds the threshold, BaseServiceDelivery::getTotal() returns 0.00. Delivery services with a zero price (e.g. take-away/self-pickup) are not affected.

The threshold is compared against the sum of basket element totals only (excluding delivery cost itself) to avoid circular pricing. Uses bccomp() for decimal precision.

Orders & Checkout

The order lifecycle is built around two guarantees:

  • Idempotency — a customer who clicks Submit twice never produces two orders.
  • Coupon consistency — a coupon code is never marked CONSUMED until payment actually succeeds.

Configuration

# config/packages/sonata_order.yaml
sonata_order:
    class:
        order:         App\Entity\Commerce\Order
        order_element: App\Entity\Commerce\OrderElement
        customer:      App\Entity\Commerce\Customer
    guest_checkout: true                # allow checkouts without registration
    pending:
        hard_timeout_hours: 24          # min: 1, max: 720 (30 days)

Entities

Entity Interface Base class
Order OrderInterface BaseOrder
OrderElement OrderElementInterface BaseOrderElement

Order status (Component\Interfaces\Order\OrderInterface):

Constant Value Meaning
STATUS_OPEN 0 Created via sendbank, payment not yet confirmed.
STATUS_PENDING 1 Payment handed to an async-capable gateway (SEPA, Klarna).
STATUS_VALIDATED 2 Payment confirmed.
STATUS_CANCELLED 3 Cancelled (sync, basket-reset, expire-sweep, or admin).
STATUS_ERROR 4 Sync payment failure not recoverable.
STATUS_STOPPED 5 Stopped by admin.
STATUS_REFUNDED 6 Fully refunded.

Order states (lifecycle diagram)

          sendbank (first submit)                  payment success webhook
Basket ─────────────────────────▶ Order.OPEN ──────────────────────────────▶ Order.VALIDATED
                                    │
                                    │  (second submit — basket changed)
                                    │      re-snapshot, same id
                                    ▼
                                  Order.OPEN (totals + coupons synced)
                                    │
     ┌──────────────────────────────┼──────────────────────────────┐
     │ async gateway (SEPA/Klarna)  │  cron sweep / admin cancel   │  payment failure
     ▼                              ▼                              ▼
Order.PENDING                 Order.CANCELLED                Order.ERROR
State Meaning
OPEN Sendbanked, payment not yet confirmed. payment_gateway_reference holds the Stripe PaymentIntent id. Second submits on the same basket reuse this row.
PENDING Payment handed to an async-capable gateway (SEPA, Klarna, some bank redirects). payment_processing_at is set; the expire-pending sweep skips these rows so a late payment_intent.succeeded webhook can still validate.
VALIDATED Payment confirmed. CouponConsumptionListener fires on PaymentEvents::CONFIRMATION and transitions reserved coupon codes to CONSUMED.
CANCELLED Cancelled by the customer (basket reset), the cron sweep, an admin action, or a sync gateway failure. OrderCanceller performs the atomic state-guarded UPDATE.
ERROR Sync payment failure that is not recoverable.

Idempotency contract

OrderIdempotencyService resolves the existing pending order for a basket — the order id is stored under Basket.options['pending_order_id']. A second sendbank on the same basket reuses the same Order row instead of creating a new one. After the basket changes, PendingOrderReSnapshotService::reSnapshot(Order, Basket) re-syncs OrderElements and totals (wrapped in a transaction by PaymentHandler::reSnapshotAtomically). Order id and reference are preserved across re-snapshots.

When you implement a custom checkout controller, route every submit through PaymentHandler::sendbank() — never construct Orders directly. BasketEvents::PRE_RESET fires before a basket is cleared so the linked pending order can be cancelled in lockstep.

Cancellation

OrderCanceller::cancel(Order, reason, ?Transaction $tx, bool $skipAsyncProcessing): bool is the single entry point for moving an order to CANCELLED. It performs:

UPDATE commerce__order
   SET status = CANCELLED, ...
 WHERE id = :id
   AND status IN (OPEN, PENDING)
   AND (:skipAsyncProcessing = false OR payment_processing_at IS NULL)

The driver-reported affected-row count is 1 on success and 0 on a lost race. On success, OrderEvents::ORDER_CANCELLED is dispatched. Two listeners react: OrderCancelListener releases reserved coupons; StripePaymentIntentCancellationSubscriber cancels the Stripe PaymentIntent.

OrderCanceller::cancel MUST NOT be called inside an open Doctrine transaction — the UPDATE-then-dispatch ordering is enforced and a violation throws \LogicException.

Cancellation reasons available on OrderEvent:

Reason When
REASON_BASKET_RESET Customer emptied their basket.
REASON_EXPIRED_PENDING Cron sweep (see below).
REASON_STRIPE_PAYMENT_FAILED Sync card decline.
REASON_ASYNC_PAYMENT_FAILED SEPA / Klarna bounce after the pending window.
REASON_STRIPE_REFUND Full refund unwinds the order.
REASON_PAYMENT_AFTER_FINAL Late webhook on an already-cancelled / errored order.
REASON_PAYPAL_CANCELLED PayPal customer-cancel on the hosted page.
REASON_MANUAL_ADMIN SonataAdmin or CLI cleanup.

Events

Constant Channel Fires when
OrderEvents::ORDER_PAID sonata.ecommerce.order.paid Payment confirmed (synchronous handler chain — PaymentSplitRecorder runs at priority 100).
OrderEvents::ORDER_CANCELLED sonata.ecommerce.order.cancelled An open / pending order was moved to CANCELLED.
OrderEvents::ORDER_FULLY_REFUNDED sonata.ecommerce.order.fully_refunded Full refund unwinds the order.
OrderEvents::PAYMENT_AFTER_FINAL sonata.ecommerce.order.payment_after_final Late webhook on an already-final order.

Expiring abandoned orders

Orders that are sendbanked but never paid stay in OPEN indefinitely. The sonata:order:expire-pending console command cancels them — and releases their coupon reservations via ORDER_CANCELLED — once they exceed the configured timeout.

# Normal run — cancels eligible orders
bin/console sonata:order:expire-pending

# Preview only — no state mutation
bin/console sonata:order:expire-pending --dry-run

# Override the configured timeout
bin/console sonata:order:expire-pending --max-age-hours=48

Each run processes up to 1000 orders (oldest first) — a safety valve against memory pressure on first runs after long outages. The command is safe to run concurrently: state-guarded UPDATEs serialise races at the DB level.

Scheduling — Symfony Scheduler

Requires dragonmantank/cron-expression (composer require dragonmantank/cron-expression). It is a suggest-only dependency of symfony/scheduler.

// src/Scheduler/SonataOrderSchedule.php
namespace App\Scheduler;

use Symfony\Component\Console\Messenger\RunCommandMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;

#[AsSchedule('default')]
class SonataOrderSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())->with(
            RecurringMessage::trigger(
                CronExpressionTrigger::fromSpec('@hourly'),
                new RunCommandMessage('sonata:order:expire-pending'),
            ),
        );
    }
}

Make sure a Messenger worker consuming the scheduler_default transport is running (bin/console messenger:consume scheduler_default, typically via Supervisor or systemd).

Scheduling — System crontab

# Cancel abandoned pending orders every hour
0 * * * *  cd /var/www/shop && /usr/bin/php bin/console sonata:order:expire-pending --no-interaction >> var/log/cron.log 2>&1

Invoices

The SonataInvoiceBundle generates invoices from paid orders and credit notes from refunded orders. Invoices are immutable historical records — they snapshot prices, VAT, addresses, and payment splits at the moment of issuance.

Configuration

# config/packages/sonata_invoice.yaml
sonata_invoice:
    class:
        invoice:         App\Entity\Commerce\Invoice
        invoice_element: App\Entity\Commerce\InvoiceElement
        order_element:   App\Entity\Commerce\OrderElement
        customer:        App\Entity\Commerce\Customer

Entities

Entity Interface Base class
Invoice InvoiceInterface BaseInvoice
InvoiceElement InvoiceElementInterface BaseInvoiceElement

Invoice status (Component\Interfaces\Invoice\InvoiceInterface):

Constant Value Meaning
STATUS_OPEN 0 Created but not yet finalised.
STATUS_PAID 1 Issued for a paid order.
STATUS_CONFLICT 2 Discrepancy between order and invoice (operator review).
STATUS_REFUNDED 3 Refunded — corresponding credit note linked via creditNoteFor.

Reference generation

Sonata\Component\Generator\ReferenceGeneratorInterface is the single contract for generating Invoice and ReturnRequest references plus credit-note ids. The default implementation produces YYMMDD000001-style references from a database sequence; override the service to swap to UUIDs, custom prefixes, etc.:

public function order(OrderInterface $order): string;
public function invoice(InvoiceInterface $invoice): string;
public function creditNote(InvoiceInterface $creditNote): string;          // CN- prefix by default
public function returnRequest(ReturnRequestInterface $rr): string;

Transformation: Order → Invoice

InvoiceTransformer builds an Invoice from a fully-paid Order. It snapshots each OrderElement into an InvoiceElement with frozen prices and VAT, and dispatches:

Constant Channel
TransformerEvents::PRE_ORDER_TO_INVOICE_TRANSFORM sonata.ecommerce.pre_order_to_invoice_transform
TransformerEvents::POST_ORDER_TO_INVOICE_TRANSFORM sonata.ecommerce.post_order_to_invoice_transform

Listen to POST_ORDER_TO_INVOICE_TRANSFORM if you need to enrich the Invoice with derived state (PDF rendering, e-invoicing exports, accounting handoff).

Credit notes

A credit note is an Invoice with negated amounts and STATUS_REFUNDED, linked to the original via creditNoteFor. CreditNoteGenerator (in SonataReturnBundle) handles the per-element negation, VAT preservation, and reference assignment. Invoice rendering can detect a credit note via $invoice->getCreditNoteFor() !== null.

Constant Channel
TransformerEvents::PRE_INVOICE_TO_CREDIT_NOTE_TRANSFORM sonata.ecommerce.pre_invoice_to_credit_note_transform
TransformerEvents::POST_INVOICE_TO_CREDIT_NOTE_TRANSFORM sonata.ecommerce.post_invoice_to_credit_note_transform

Voucher-sale invoice variants

When sellable gift vouchers are enabled, the bundle adds four columns to commerce__invoice:

Column Meaning
is_voucher_sale TRUE on Mehrzweckgutschein-Ausstellung invoices.
voucher_code_id Issued or redeemed CouponCode (semantic depends on is_voucher_sale).
voucher_paid_amount Snapshot from Order.payment_splits at invoice creation.
card_paid_amount Snapshot from Order.payment_splits at invoice creation.

Both monetary columns are BCMath strings at scale 4. See Vouchers > Sellable gift vouchers > Liability accounting for the full flow.

Discounts & Coupons

The SonataDiscountBundle covers discount rules, coupon codes, and the discount allocation that drives partial-refund correctness.

Coupon state machine

   create code
       │
       ▼
   AVAILABLE ────reserve()────▶ RESERVED ────consume()────▶ CONSUMED
       ▲                          │                            │
       └──── release() ◀──────────┘                            │
       ▲                                                       │
       └──── releaseConsumed() / refund unwind ◀───────────────┘

Two terminal states extend the machine for vouchers (covered in Vouchers):

  • STATE_DISABLED — auto-disable when the refund-cycle cap is hit.
  • STATE_CANCELLED — voucher-sale return: a sellable voucher refunded back to the customer's card.
Transition Method Triggered by
AVAILABLE → RESERVED CouponReservationService::reserve() OrderDiscountManager::createFromBasketLine at sendbank or re-snapshot
RESERVED → CONSUMED CouponReservationService::consume() CouponConsumptionListener::onConfirmation on PaymentEvents::CONFIRMATION
RESERVED → AVAILABLE CouponReservationService::release() (and releaseByCouponCodeId() / releaseAllForOrder()) OrderCancelListener on ORDER_CANCELLED
CONSUMED → AVAILABLE CouponReservationService::releaseConsumed() (non-voucher codes) CouponRefundReleaseListener on ORDER_FULLY_REFUNDED
any → DISABLED CouponReservationService::reloadBalance() (when cycle cap exceeded) Refund-cycle cap auto-disable
AVAILABLE → CANCELLED VoucherSaleRefundListener Sellable-voucher return (refund_sale ledger event)

Atomicity

Every transition is implemented as a state-guarded UPDATE — winners affect 1 row, losers affect 0:

UPDATE discount__coupon_code
   SET state = 'RESERVED',
       reserved_at = NOW(),
       reserved_order_id = :oid
 WHERE id = :id
   AND state = 'AVAILABLE'

A lost race raises CouponConflictException; CouponConflictListener maps it to a checkout redirect with a flash message. The same pattern guards consume, release, and releaseConsumed.

Counter semantics

Coupon.current_redemption_count is incremented atomically on RESERVED → CONSUMED and decremented on CONSUMED → AVAILABLE. The decrement floors at zero (GREATEST(current_redemption_count - 1, 0)) so manual writes or concurrent edits cannot push the counter negative.

Reservation contract

A successful reserve() is a commitment: consume() does not re-check the parent Coupon state. This keeps the consume path fast and avoids the race where an admin disables a coupon mid-checkout. To withdraw a coupon while reservations exist, disable it from the admin and let the existing reservations flow through; they will not be honored on new checkouts because the parent state is checked at applyCode time.

Discount allocation on partial returns

When a customer returns part of a discounted order, the legacy refund formula Σ unit_price_inc × return_qty over-refunds by the discount per unit — the shop ate the loss; the customer was effectively refunded a copy of their discount on top of the goods price. The bundle now allocates each OrderDiscount row weighted by line value across the matching OrderElement rows at sendbank time, so the refund calculator can subtract the per-unit allocation when a partial return is processed.

Worked example — 3 × €100 with -10%, customer pays €270 and returns 2 of 3 units:

Mode Refund
Legacy 2 × €100 = €200 (over-refund)
Allocation-aware (default) 2 × (€100 − €10) = €180

Configuration

# config/packages/sonata_discount.yaml
sonata_discount:
    refund:
        new_allocation_enabled: true       # bool, default true
        partial_return_policy: lenient     # 'lenient' (default) | 'strict'
  • new_allocation_enabled — when true (default), RefundAmountCalculator uses the allocation-aware policy branches. Set to false to fall back to the legacy formula (BC-safe escape hatch — both the calculator and the admin / CLI re-allocation entry points warn-and-no-op under flag-off).
  • partial_return_policylenient (canonical: per-element max(0, unit_price_inc − allocated_per_unit) × return_qty) or strict (revokes the coupon retroactively, refund = Order.total_inc − Σ retained × list_price).

Free-shipping-threshold reductions, once applied at order time, are retained on a partial return that drops the basket below the threshold — this matches §357 BGB / §14 FAGG legal review.

Backfill historical orders

bin/console sonata:discount:backfill-allocations [--dry-run] [--since=YYYY-MM-DD] [--limit=N] [--force]
  • --dry-run — preview, no persistence.
  • --since=YYYY-MM-DD — backfill orders created on or after this date.
  • --limit=N — stop after N orders.
  • --force — bypass the new_allocation_enabled: false guard. Without --force, when the feature flag is off the command warns and exits successfully (no-op).

The command is idempotent: a second run reports zero candidates because the first run populated the columns. It uses per-batch transactions of 100 rows, so a long backfill cannot trigger PostgreSQL's idle_in_transaction_session_timeout on multi-million-row tables.

Re-allocate a single order

Two entry points re-run the allocator on an existing order:

  • bin/console sonata:discount:reallocate-order <orderId> — prints a before/after diff and persists. Idempotent.
  • Admin button on OrderAdmin::show for users with the Sonata-Admin EDIT ACL.

Both warn-and-no-op when new_allocation_enabled: false. Each successful run writes an info-level audit log entry with order_id, order_reference, and trigger (cli or admin_button).

Defensive invariant check

AllocationInvariantListener (Doctrine onFlush, dev + prod only) verifies that Σ OrderElement.allocated_discount_inc == Σ eligible OrderDiscount.discount_amount on every flush. In dev it raises LogicException; in prod it logs at error level and continues. The listener only checks orders affected by the current flush — STATUS_OPEN orders are skipped because the allocator runs on PaymentEvents::POST_SENDBANK, after the BasketTransformer's intermediate flushes.

Extension points

  • Sonata\DiscountBundle\Service\DiscountAllocator is final. To swap allocators, alias your implementation onto Sonata\Component\Interfaces\Discount\DiscountAllocatorInterface:
    # config/services.yaml
    Sonata\Component\Interfaces\Discount\DiscountAllocatorInterface:
        alias: App\Discount\MyCustomAllocator
  • Sonata\Component\Interfaces\Discount\OrderDiscountManagerInterface::getDiscountsForOrder(int $orderId): array is the canonical entry to load OrderDiscount rows; override per the standard manager-replacement convention.

Vouchers

Voucher coupons (Coupon.isVoucher = true) carry a balance that is debited at consume, credited back at refund, and persisted as an auditable ledger of every change. Two flavours are supported:

  • Admin-issued vouchers — created from the Sonata admin as a Coupon row with isVoucher = true, then minted as CouponCode rows. Always available.
  • Sellable gift vouchersVoucherProduct purchased through the shop; on payment the bundle automatically issues a CouponCode and writes a liability ledger row. Gated by voucher.sellable (default false).

Worked example — €50 voucher applied to a €60 order, customer returns the line item:

  • Card-first refund: €10 returns to the original payment method.
  • Remaining €50 is reloaded onto the voucher.
  • Voucher state goes back to AVAILABLE; the customer keeps their voucher liability.

There is no cash-out path through voucher refunds — §1478 ABGB / §241 BGB Restguthaben-Pflicht is honoured.

Configuration

All keys live under sonata_discount.voucher.*:

Key Type Default Effect
balance_model_enabled bool true Master switch for the balance model. false falls back to the legacy releaseConsumed path — historical refunds stay card-only.
max_vouchers_per_order int 1 Enforced by CouponValidator in both applyCode and revalidateAppliedCodes.
max_refund_cycles_per_code int 3 Cap on refund_cycle_count. Past-cap refunds escalate to the overflow queue.
expired_reload_grace_days int 30 Grace window after Coupon.ends_at. Refunds inside the window still reload; past the window go to the overflow queue.
auto_disable_on_max_cycles bool true At cycle cap, flips parent Coupon.enabled = false with audit reason disable_max_cycles.
sellable bool false Master switch for sellable gift vouchers. Flip to true only after the pre-release checklist (see below) is green.
default_expiry_years int 3 Default ends_at offset (NOW() + N years) when a VoucherProduct does not specify its own.
fixed_skus string[] ['10','25','50','100'] Allowed face-value SKUs for VoucherProduct (BCMath-safe strings).
max_voucher_purchase_quantity_per_order int 50 Anti-abuse cap on VoucherProduct quantity per basket — each issued voucher costs one transaction + ledger row + email dispatch.
allow_partially_redeemed_voucher_return bool false Operator override that lets a customer return a partially-redeemed sellable voucher. Default rejects with voucher.already_redeemed_not_returnable.

Example:

# config/packages/sonata_discount.yaml
sonata_discount:
    voucher:
        balance_model_enabled: true
        max_vouchers_per_order: 1
        max_refund_cycles_per_code: 3
        expired_reload_grace_days: 30
        auto_disable_on_max_cycles: true
        sellable: false

Balance model

Voucher columns on discount__coupon_code (NUMERIC(20,4) unless noted):

Column Notes
original_value_inc / _excl Pinned at creation, never mutated.
remaining_value_inc / _excl Live balance. Voucher codes carry these; non-voucher codes leave NULL.
reserved_amount_inc / _excl Provisional decrement during RESERVED. Set by reserve(), cleared by release() or consume().
grace_expiry_at (TIMESTAMP NULL) Set when the grace-period window applies; refunds past this point are escalated.
refund_cycle_count (INTEGER NOT NULL DEFAULT 0) Monotonically incremented on every successful reload_refund. Compared against max_refund_cycles_per_code.

Running balance equation: remaining_value_inc = original_value_inc + SUM(delta_inc). Reconcile from the ledger:

SELECT SUM(delta_inc) FROM discount__coupon_code_transaction WHERE coupon_code_id = :id

Transaction ledger

discount__coupon_code_transaction is the append-only ledger. UNIQUE (coupon_code_id, source_refund_id) is the webhook-retry idempotency guard: replaying the same Stripe event cannot double-credit a code. source_refund_id is NULL for admin entries.

Reason vocabulary (Sonata\Component\Discount\LedgerEventType):

Reason Delta When
consume negative Code redeemed at PaymentEvents::CONFIRMATION.
reload_refund positive Refund credited back via VoucherRefundAllocator → reloadBalance.
release_cancel zero Pre-payment cancel. The delta=0 rule: remaining_value_inc was never decremented, only reserved_amount_inc was, so cancelling clears the reservation without touching the balance.
admin_topup positive Manual admin credit.
admin_adjust signed Manual admin correction.
disable_max_cycles zero Paired with auto-disable when refund_cycle_count > max_refund_cycles_per_code.
overflow_escalation zero Reload suppressed (past cap or past grace) — surfaces in the admin overflow queue.
expire negative, floor 0 Post-grace sweep.

Manager API (Sonata\DiscountBundle\Manager\CouponCodeTransactionManager):

// CRUD primitives (inherited from AbstractEntityManager)
$tx->create(): CouponCodeTransactionInterface;
$tx->save(object $entity): void;
$tx->delete(object $entity): void;

// Append + read helpers
$tx->append(...): CouponCodeTransactionInterface;
$tx->findRecentForCode(int $couponCodeId, int $limit = 50): array;
$tx->findLatestConsumeFor(int $couponCodeId, int $orderId): ?CouponCodeTransactionInterface;

// Webhook idempotency guard — true if a row for this Stripe refund id already exists
$tx->existsForRefund(int $couponCodeId, string $sourceRefundId): bool;

getRunningBalance() lives on the CouponCodeTransactionInterface entity itself — every row stores its post-append balance so timeline rendering needs no recomputation.

Refunds with vouchers

VoucherRefundAllocator::allocate(OrderInterface $order, string $refundAmountInc): VoucherRefundPlan implements the card-first policy: the captured-card amount goes to the original payment method first, the remainder reloads the voucher via CouponReservationService::reloadBalance(...). The plan describes the per-method split — RefundProcessor::executeVoucherReloads(...) consumes it and writes the corresponding reload_refund ledger rows.

The allocator is stateless and idempotent: replaying the same (Order, refundAmountInc) pair returns the same plan.

Stripe idempotency

Refund idempotency key format: refund-{ReturnRequest.reference}. ReturnRequest.reference is pinned at creation, so the key is stable for the full lifetime of the refund. RefundProcessor wraps voucher reloads in wrapInTransaction so the Stripe API call and the ledger row commit atomically.

The charge.refund.updated Stripe webhook carries async-pending refunds (SEPA, Klarna) from pending to succeeded; the ledger row is written on the succeeded transition, never on pending creation. Subscribe to it in your Stripe dashboard alongside the other events listed in Stripe webhook setup.

Grace period

expired_reload_grace_days (default 30) sets a window after Coupon.ends_at during which a refund still credits the voucher balance. Past the window, reloadBalance writes an overflow_escalation row with context = 'grace_expired' and the admin queue surfaces the case for manual handling.

Cycle cap and overflow queue

max_refund_cycles_per_code (default 3) caps how often a single voucher can be reloaded. When the next reload would push past the cap:

  1. reloadBalance writes a disable_max_cycles ledger row (delta 0).
  2. If auto_disable_on_max_cycles = true (default), parent Coupon.enabled flips to false with a Sonata audit-log entry.
  3. The CouponCode transitions to STATE_DISABLED (terminal). Further refunds against it write overflow_escalation rows for admin handling.

This guards against runaway refund cycles (a malicious actor running buy → return → buy → return loops to extract value through the card-first split).

Listener priority on ORDER_PAID

Two listeners on OrderEvents::ORDER_PAID:

Priority Listener Reaction
100 Sonata\DiscountBundle\EventListener\PaymentSplitRecorder Writes Order.payment_splits from underlying transactions. Runs FIRST.
0 Sonata\DiscountBundle\EventListener\VoucherIssueListener (sellable feature) Reads payment_splits to decide voucher issuance.

Subscribers reading Order.payment_splits MUST register at priority < 100. Reading before PaymentSplitRecorder has run yields stale data.

The dispatcher uses a try/catch idempotency pattern: the Order.order_paid_event_dispatched flag is set ONLY after the full listener chain completes successfully. A partial-failure replay re-runs the entire chain — every listener MUST be idempotent. The combined predicate Order.paymentStatus = PAID AND order_paid_event_dispatched = true is the dual idempotency guard.

OrderEvents::ORDER_PAID is dispatched by:

  • PaymentHandler — synchronous path (customer returns from Stripe Checkout, payment already confirmed).
  • StripeWebhookController — asynchronous path (payment_intent.succeeded webhook, routed through Symfony Messenger for exactly-once handling).

Zero-amount orders

When a voucher (or stacked discounts) covers the whole basket, Order.total_inc <= 0. Such orders are settled in-process by PaymentHandler::handleZeroAmountOrder instead of being passed to a payment gateway (Stripe / PayPal / Ogone reject sub-minimum charges). The handler synthesises a zero_amount-coded transaction, dispatches PaymentEvents::CONFIRMATION (so CouponConsumptionListener decrements the voucher balance) and OrderEvents::ORDER_PAID (so PaymentSplitRecorder writes payment_splits), then redirects the customer to /shop/payment/confirmation?bank=zero_amount.

Idempotency uses the same Order.order_paid_event_dispatched flag as the regular paid flow.

Admin operations

Ledger tab

The CouponCode show page renders a read-only ledger tab — chronological, filterable. Translation keys: ledger.col_when, ledger.col_reason, ledger.col_delta, ledger.col_balance, ledger.col_order, ledger.col_refund. Overflow rows are decorated with ledger.overflow_warning. Role gating follows the bundle's existing CouponCode ACL.

Manual adjustments

The CouponCodeAdmin form inserts an admin_topup or admin_adjust ledger row and updates remaining_value_inc atomically. Every adjustment writes to the Sonata audit log.

Overflow queue

Past-cap and past-grace refunds surface on the admin dashboard as an overflow-queue block. The "mark handled" action is CSRF-validated and emits flash.overflow_resolved on success. Resolution is a manual write — typically refund to the original payment method outside the bundle.

Liability dashboard (sellable vouchers)

When voucher.sellable = true, the admin dashboard renders a Voucher Liability block via VoucherLiabilityDashboardBlockService: outstanding liability, per-event totals, recent issuances. Reads VoucherLiabilityLedgerManager reporting helpers (sumByMonth, findRecentByEventType).

Customer-facing

Public balance page

GET/POST /voucher/balance lets customers check their voucher balance. Symfony Rate Limiter is configured at 5 requests / minute / IP (token bucket). The page applies an anti-enumeration UX policy: not-found, expired, and fully-consumed codes all return the same generic "invalid or expired" response — never reveals whether a code exists.

Voucher-reload email

After a successful refund-reload, reloadBalance dispatches CouponCodeReloadedEvent on the DiscountEvents::COUPON_CODE_RELOADED channel. A Messenger handler renders emails/voucher_reload.html.twig (translation domain emails). The default template includes a card-timing disclaimer ("Refunds to card post in 5–10 business days; voucher credit is immediate") to head off support tickets.

Voucher-purchased email (sellable vouchers)

After a sellable-voucher purchase, the bundle dispatches SendVoucherPurchasedEmailMessage async via Symfony Messenger. The bundle ships the dispatch + DTO; consuming apps wire up the handler, Twig templates, and a ShopMailService shim — see the demo app for a reference implementation.

Sellable gift vouchers

Builds on the balance model to make voucher CouponCodes purchasable as a product in the shop. Customers buy a Geschenkkarte (e.g. €10 / €25 / €50 / €100), receive a redeemable code by email, and the shop's accounting reflects Mehrzweckgutschein (multi-purpose voucher) treatment under AT §9(4) UStG / DE §3(13-15) UStG: the sale is a liability, revenue is recognised at redemption.

Enabling the feature

# config/packages/sonata_discount.yaml
sonata_discount:
    voucher:
        sellable: true

Default is false. While off, the sellable code paths (issue listener, voucher-sale invoice variant, return branch, dashboard block) sit inert in the container without affecting the regular voucher-redemption flow.

Issue flow

        customer buys VoucherProduct
                   │
              ORDER_PAID
                   │
       ┌──────────┴──────────┐
       │                     │
  priority 100         priority 0
PaymentSplitRecorder   VoucherIssueListener
                              │
                  ┌──────────┴──────────┐
                  ▼                     ▼
        CouponCode created        voucher_purchased email
        (state=AVAILABLE,         (Messenger async)
         is_voucher=true,
         origin_order_element_id)
                  │
            wrapInTransaction
                  │
                  ▼
         voucher_liability_ledger
         (event_type='issue', delta_inc=+face_value)

Liability accounting

Append-only ledger discount__voucher_liability_ledger records every issue, redemption, refund_sale, expire, and reload. The CHECK constraint literals match LedgerEventType::liabilityLedgerTypes() — kernel-booted CI test enforces equality.

Invoice variants (commerce__invoice columns):

Column Meaning
is_voucher_sale TRUE on the Mehrzweckgutschein-Ausstellung invoice.
voucher_code_id Issued or redeemed CouponCode (semantic depends on is_voucher_sale).
voucher_paid_amount Snapshot from Order.payment_splits at invoice creation.
card_paid_amount Snapshot from Order.payment_splits at invoice creation.

Snapshot — not join-time compute — because invoices are immutable historical records. Both columns are BCMath strings at scale 4; templates never need BCMath arithmetic.

VoucherProduct

A VoucherProduct is a Single-Table-Inheritance child of Product with a face_value_inc column. Register your concrete entity:

// src/Entity/Commerce/VoucherProduct.php
use Sonata\DiscountBundle\Entity\BaseVoucherProductTrait;

#[ORM\Entity]
class VoucherProduct extends Product
{
    use BaseVoucherProductTrait;
}

Restrictions

  • Voucher-on-voucher — customers cannot pay for a voucher purchase with another voucher (closes a cash-out fraud vector). CouponValidator::validate rejects with voucher.applicable.not_to_voucher_purchase.
  • Disabled / cancelledCouponValidator short-circuits on STATE_DISABLED (cycle-cap auto-disable, see balance model) and STATE_CANCELLED (sellable-voucher refund_sale) with voucher.disabled / voucher.cancelled.

Voucher-sale returns

ReturnHandler::requestReturn gates voucher-sale returns at request time:

  • Untouched (remaining_value_inc == original_value_inc) — return allowed → full card refund + CouponCode.state = CANCELLED + liability ledger refund_sale row.
  • Partially redeemed — return rejected with voucher.already_redeemed_not_returnable. Operator override available via voucher.allow_partially_redeemed_voucher_return: true.

Card-side refund flows through the existing RefundProcessor with the refund-{ReturnRequest.reference} Stripe idempotency key — no new key shape.

Operations

sonata:discount:expire-vouchers — nightly cron that retires voucher CouponCodes whose parent Coupon.ends_at + expired_reload_grace_days has elapsed. For each: zeroes balance, writes liability expire row, writes redemption expire row. Per-batch transaction (size 100). Idempotent.

bin/console sonata:discount:expire-vouchers --dry-run
bin/console sonata:discount:expire-vouchers --no-interaction

Schedule with Symfony Scheduler or system crontab — same patterns as the order expiry command above.

sonata:discount:export-customer-vouchers — GDPR Art. 15 (DSAR) exporter for voucher data. Outputs every CouponCode the customer purchased (origin FK chain) or redeemed (CouponCodeTransaction join — captures bearer-voucher cases) plus the matching ledger rows. The output JSON carries a schema_version field (currently '1.0') so downstream consumers can detect BC breaks. Read-only.

bin/console sonata:discount:export-customer-vouchers <customerId> --format=json
bin/console sonata:discount:export-customer-vouchers <customerId> --output=/tmp/dsar-<id>.json
bin/console sonata:discount:export-customer-vouchers <customerId> --no-include-redemption-detail

Marketing voucher exclusion

Admin-created promotional codes (isVoucher = true but origin_order_element_id IS NULL) are EXPENSES, not customer liabilities. VoucherLiabilityLedgerService checks isPurchasedVoucher at every record method — admin marketing vouchers never write to the liability ledger. The dashboard, expire command, and DSAR exporter all inherit this exclusion automatically.

Pre-release checklist

Before flipping voucher.sellable: true in production, the operator should work through:

  • Tax-advisor sign-off (invoice template, liability ledger, expired-voucher treatment, VAT split semantics).
  • GDPR sign-off (retention policy, DSAR mechanism, privacy policy update).
  • Functional smoke (buy / redeem / return / reject-redeemed-return / voucher-on-voucher / expire / dashboard / DSAR).
  • Master deploy runbook (composer + migrations + cache + smoke + cron + flag flip).
  • Emergency rollback procedure.

Returns & Refunds

The SonataReturnBundle provides a complete return / refund system: customer self-service, admin workflow, Stripe refund integration, and automatic credit note generation. Refunds for orders that involved discounts or vouchers go through the allocation-aware refund calculator (see Discounts & Coupons and Vouchers).

Configuration

# config/packages/sonata_return.yaml
sonata_return:
    class:
        return_request: App\Entity\Commerce\ReturnRequest
        return_element: App\Entity\Commerce\ReturnElement
    return_period_days: 14            # days after delivery for eligible returns
    auto_approve_within_period: true  # auto-approve if within the return period
    require_goods_receipt: true       # require admin to confirm goods received
    reasons:                          # configurable return reason codes
        - defective
        - wrong_item
        - not_as_described
        - changed_mind
        - other

Entities

Entity Interface Base class
ReturnRequest ReturnRequestInterface BaseReturnRequest
ReturnElement ReturnElementInterface BaseReturnElement

Concrete app-side entity skeleton:

// src/Entity/Commerce/ReturnRequest.php
#[ORM\Entity]
#[ORM\Table(name: 'commerce__return_request')]
class ReturnRequest extends BaseReturnRequest
{
    #[ORM\ManyToOne(targetEntity: Order::class)]
    #[ORM\JoinColumn(nullable: false)]
    protected $order = null;

    #[ORM\ManyToOne(targetEntity: Customer::class)]
    protected $customer = null;

    #[ORM\OneToMany(targetEntity: ReturnElement::class, mappedBy: 'returnRequest',
        cascade: ['persist', 'remove'], orphanRemoval: true)]
    protected Collection $returnElements;
}

Workflow

REQUESTED(0) ────▶ APPROVED(1) ────▶ RECEIVED(3) ────▶ REFUNDED(4)
    │   │              │
    │   └──▶ REJECTED(2)
    │
    └──▶ CANCELLED(5)
State Meaning
REQUESTED Customer submitted the request (auto-approved if within the return period).
APPROVED Admin approved (or auto-approved).
REJECTED Admin rejected with reason. Only valid from REQUESTED.
RECEIVED Admin confirmed goods receipt.
REFUNDED Payment refunded via the gateway, credit note generated.
CANCELLED Customer or admin cancelled. Valid from REQUESTED or APPROVED.

Customer self-service

Import the return routes:

# config/routes/sonata_return.yaml
sonata_return:
    resource: '@SonataReturnBundle/Resources/config/routing/return.php'
    prefix: /shop/return

Routes provided (defined as attributes on Sonata\ReturnBundle\Controller\ReturnController):

Route name Method Path Purpose
sonata_return_request GET / POST /shop/return/request/{reference}/{token} Return request form + submission.
sonata_return_view GET /shop/return/view/{returnReference}/{token} Return status view.

Token-based authentication uses hash_equals on order.accessToken. The sonata_order.guest_checkout config flag must be enabled for guest checkouts to receive an access token.

For a guest "look up my return" landing page, wire your own controller using the reusable Sonata\OrderBundle\Form\Type\GuestOrderLookupType (the same primitive that backs the order lookup page).

Admin workflow

The admin lists, filters, and shows return requests with coloured status labels and action buttons:

  • Approve — REQUESTED → APPROVED.
  • Reject — REQUESTED → REJECTED (requires admin notes).
  • Confirm goods receipt — APPROVED → RECEIVED.
  • Process refund — RECEIVED → REFUNDED (triggers gateway refund + credit note).

All admin actions are CSRF-protected. The admin group is sonata_ecommerce.

Refund flow

RefundProcessor orchestrates the refund:

  1. Creates a refund Transaction.
  2. Resolves the payment provider via PaymentSelector.
  3. Computes the refund amount via RefundAmountCalculator (allocation-aware — see Discounts & Coupons).
  4. Calls $payment->refund(...) with a stable idempotency key (refund-{ReturnRequest.reference}).
  5. For voucher-paid orders, runs VoucherRefundAllocator first to compute the card-first split (see Vouchers > Refunds with vouchers).
  6. Updates Order paymentStatus to PARTIALLY_REFUNDED or REFUNDED.
  7. Generates a credit note via CreditNoteGenerator.

For Stripe Dashboard-initiated refunds, add charge.refunded and charge.refund.updated to your Stripe webhook subscription.

Events

Constant Channel Fires when
ReturnEvents::RETURN_REQUESTED sonata.ecommerce.return.requested Return request created.
ReturnEvents::RETURN_APPROVED sonata.ecommerce.return.approved Approved (admin or auto).
ReturnEvents::RETURN_REJECTED sonata.ecommerce.return.rejected Rejected by admin.
ReturnEvents::RETURN_RECEIVED sonata.ecommerce.return.received Goods receipt confirmed.
ReturnEvents::RETURN_REFUNDED sonata.ecommerce.return.refunded Refund processed.
ReturnEvents::RETURN_CANCELLED sonata.ecommerce.return.cancelled Cancelled by customer or admin.

Credit notes

Every refund generates a credit note (a negative Invoice):

  • Linked to the original Invoice via creditNoteFor self-reference.
  • Element prices, totalExcl, and totalInc are negated.
  • VAT rate is preserved (not negated).
  • Reference format: CN-YYMMDD000001 (configurable via ReferenceGeneratorInterface::creditNote).
  • Status: STATUS_PAID (immediately effective).

Upgrading

From 4.3.x to 4.4.0

BREAKING — PaymentHandlerInterface::handleZeroAmountOrder

A new required method has been added to PaymentHandlerInterface:

public function handleZeroAmountOrder(OrderInterface $order): void;

The default PaymentHandler ships the implementation. Hosts that subclass PaymentHandler are unaffected. Hosts that re-implement the interface from scratch must add the method. See Zero-amount orders for the contract.

Database migrations (consuming app)

Generate via bin/console doctrine:migrations:diff after the composer update. The bundle ships the entity-side attributes; the migrations live in the consuming app. The reference demo-app migrations (provided as templates):

Migration Purpose
Version20260429145040 Allocation columns on OrderElement and OrderDiscount — additive, includes a guarded historical backfill of is_voucher.
Version20260503120000 Voucher balance columns, transaction ledger, payment_splits JSON, ORDER_PAID idempotency flag — additive, hot-deploy safe.
Version20260504100000 Flips fk_cct_coupon_code to ON DELETE RESTRICT so a CouponCode delete cannot silently shred the audit trail.
Version20260505120000 Voucher liability ledger table, sellable-voucher invoice columns, origin_order_element_id FK on coupon_code.
Version20260505130000 face_value_inc column on commerce__product (VoucherProduct STI child).
Version20260505140000 voucher_paid_amount + card_paid_amount snapshot columns on commerce__invoice.
Version20260506000000 Partial UNIQUE index on parent voucher Coupon name to defend against concurrent first-sale-of-SKU dispatchers.
Version20260506000001 source_event_id column + partial UNIQUE on the voucher liability ledger to make replay-safe.
Version20260506000002 Bumps TIMESTAMP precision on the voucher liability ledger so multi-quantity issuances within the same second don't collide on the timeline index.

For shops with > 1M OrderDiscount rows, run the allocation migration in a low-traffic window — the data-update step holds row locks for the full UPDATE duration.

Backfill commands (run in this order)

# 1. Initialise voucher balance columns on pre-existing voucher codes.
bin/console sonata:discount:backfill-voucher-balances --dry-run
bin/console sonata:discount:backfill-voucher-balances --no-interaction

# 2. Reconstruct Order.payment_splits from underlying transactions. MUST run after #1.
bin/console sonata:discount:backfill-payment-splits --no-interaction

# 3. Backfill historical OrderElement allocation columns. Optional — required only if you want correct partial-refund math on legacy orders.
bin/console sonata:discount:backfill-allocations --dry-run
bin/console sonata:discount:backfill-allocations --no-interaction

Both voucher-related backfills are idempotent and skip CANCELLED orders. Re-runs are safe.

Stripe webhook subscription

Add charge.refunded and charge.refund.updated to your Stripe Dashboard webhook subscription if they are not already wired. charge.refunded carries the bundle-initiated full-refund unwind; charge.refund.updated carries async-pending refunds (SEPA, Klarna) from pending to succeeded — both are required for the voucher-reload ledger row to land at the correct moment. See Stripe webhook setup for the full event list.

Configuration

The new sonata_discount.refund.* and sonata_discount.voucher.* keys all default to safe values. Review the Discounts & Coupons and Vouchers sections for the full table; set voucher.sellable: true only after the pre-release checklist is green.

Rollback

  • sonata_discount.refund.new_allocation_enabled: false — falls back to the legacy refund formula.
  • sonata_discount.voucher.balance_model_enabled: false — falls back to the pre-balance-model voucher path; new orders write no ledger row and no Order.payment_splits. Existing data is preserved on disk; re-enabling picks them back up without loss.
  • sonata_discount.voucher.sellable: false — disables sellable gift vouchers; existing issued codes remain redeemable.

The migrations are reversible (down() drops the columns and ledger tables cleanly), but rolling back below the migration loses historical ledger data irreversibly. Prefer the feature-flag rollback over a schema rollback.

Credits

License

This project is licensed under the MIT License — see the LICENSE file for details.