smartlabs / sonata-ecommerce
Ecommerce solution for Symfony and Sonata Admin (products, orders, payments, invoices, returns, refunds)
Package info
github.com/smartlabsAT/sonata-project-ecommerce
Type:symfony-bundle
pkg:composer/smartlabs/sonata-ecommerce
Requires
- php: ^8.3
- ext-bcmath: *
- cocur/slugify: ^4.0
- doctrine/dbal: ^4.0
- doctrine/doctrine-bundle: ^2.18
- doctrine/orm: ^3.6
- knplabs/knp-menu-bundle: ^3.0
- knplabs/knp-paginator-bundle: ^6.0
- psr/log: ^3.0
- sonata-project/admin-bundle: ^4.42
- sonata-project/block-bundle: ^5.4
- sonata-project/classification-bundle: ^4.0
- sonata-project/doctrine-orm-admin-bundle: ^4.21
- sonata-project/exporter: ^3.0
- sonata-project/form-extensions: ^2.7
- sonata-project/formatter-bundle: ^5.0
- sonata-project/intl-bundle: ^3.0
- sonata-project/twig-extensions: ^2.6
- stripe/stripe-php: ^20.0
- symfony/config: ^7.4
- symfony/console: ^7.4
- symfony/dependency-injection: ^7.4
- symfony/doctrine-bridge: ^7.4
- symfony/event-dispatcher: ^7.4
- symfony/form: ^7.4
- symfony/framework-bundle: ^7.4
- symfony/http-client: ^7.4
- symfony/http-foundation: ^7.4
- symfony/http-kernel: ^7.4
- symfony/intl: ^7.4
- symfony/lock: ^7.4
- symfony/options-resolver: ^7.4
- symfony/property-access: ^7.4
- symfony/rate-limiter: ^7.4
- symfony/routing: ^7.4
- symfony/security-core: ^7.4
- symfony/serializer: ^7.4
- symfony/translation: ^7.4
- symfony/twig-bundle: ^7.4
- symfony/validator: ^7.4
- symfony/var-exporter: ^7.4
- twig/string-extra: ^3.0
- twig/twig: ^3.0
Requires (Dev)
- friendsofsymfony/rest-bundle: ^3.0
- infection/infection: ^0.32
- matthiasnoback/symfony-config-test: ^5.2 || ^6.0
- matthiasnoback/symfony-dependency-injection-test: ^5.1 || ^6.0
- phpunit/phpunit: ^11.0
- sonata-project/media-bundle: ^4.0
- sonata-project/seo-bundle: ^3.0
- symfony/messenger: ^7.4
- symfony/phpunit-bridge: ^7.4
- symfony/process: ^7.4
Suggests
- friendsofsymfony/rest-bundle: For REST API endpoints (product, basket, order, customer, invoice APIs)
- nelmio/api-doc-bundle: For API documentation generation
- sonata-project/media-bundle: For product images and galleries
- sonata-project/seo-bundle: For SEO meta tags on product and order pages
- stof/doctrine-extensions-bundle: For Gedmo Sluggable support on Product entities
- symfony/filesystem: For GenerateProductCommand file operations
- symfony/messenger: For async order processing and stock updates after payment
Conflicts
- 4.x-dev
- 4.4.0
- 4.3.0
- 4.2.0
- 4.1.2
- 4.1.1
- 4.1.0
- 4.0.3
- 4.0.2
- 4.0.1
- 4.0.0
- dev-fix/243-voucher-redemption-bugs
- dev-epic/204-sellable-gift-vouchers
- dev-epic/187-voucher-payment-mechanics
- dev-feature/188-data-model-migration
- dev-feature/186-documentation
- dev-feature/185-integration-tests
- dev-feature/184-backfill-command
- dev-feature/183-credit-note-breakdown
- dev-feature/182-admin-realloc-trigger
- dev-feature/181-refund-calculator
- dev-feature/180-allocator-integration
- dev-feature/179-discount-allocator
- dev-feature/178-allocation-entity-changes
- dev-feature/221-shared-constants
- dev-chore/ci-trigger-on-epic-branches
- dev-epic/131-order-idempotency-coupon-reservation
- dev-feature/131-docs-epic-closed
- dev-feature/143-documentation
- dev-feature/160-late-payment-webhook-cancelled-order
- dev-feature/159-dashboard-refund-coupon-release
- dev-feature/158-async-payment-states-sepa-klarna
- dev-feature/141-resnapshot-coupon-sync
- dev-feature/140-stripe-payment-intent-sync
- dev-feature/138-expire-pending-orders-command
- dev-feature/168-basketsessionfactory-dual-slot-mirror
- dev-feature/157-min-order-amount-resnapshot-validation
- dev-feature/156-coupon-reservation-contract-policy
- dev-feature/163-basket-reset-listener-ownership-check
- dev-feature/153-integration-test-suite
- dev-feature/155-customer-scoped-idempotency
- dev-feature/154-basket-transformer-transactional
- dev-feature/151-atomic-order-canceller
- dev-feature/137-event-architecture-cancel-listeners
- dev-feature/134-pending-order-resnapshot
- dev-feature/133-order-idempotency-service
- dev-feature/139-basket-coupon-subscriber-short-circuit
- dev-feature/135-coupon-reservation-service
- dev-epic/109-discount-bundle
- dev-feature/107-free-shipping-threshold
- dev-fix/twig3-order-index-for-if
- dev-feature/104-guest-order-lookup-form
- dev-docs/update-changelog-101
- dev-fix/101-return-integration-audit
- dev-fix/99-block-service-string-aliases
- dev-fix/97-missing-admin-translations
- dev-fix/95-register-return-crud-controller
- dev-feature/78-documentation
- dev-fix/92-credit-note-total-excl
- dev-feature/77-comprehensive-test-suite
- dev-feature/76-serializer-normalizers
- dev-feature/75-translations-en-fr
- dev-feature/74-customer-self-service-return
- dev-feature/73-return-admin-crud
- dev-feature/72-credit-note-generator
- dev-feature/71-stripe-webhook-charge-refunded
- dev-feature/70-payment-refund-processor
- dev-feature/69-return-handler-business-logic
- dev-feature/68-return-status-renderer-events
- dev-feature/67-return-bundle-skeleton
- dev-feature/66-base-return-entities
- dev-feature/65-return-interfaces-status-constants
- dev-feature/49-stripe-docs
- dev-feature/48-stripe-unit-tests
- dev-feature/47-stripe-translation-keys
- dev-feature/46-stripe-templates
- dev-feature/44-stripe-webhook-controller
- dev-feature/43-stripe-payment-class
- dev-feature/45-stripe-configuration
- dev-fix/product-extension-return-types
- dev-fix/seo-null-meta-values
- dev-fix/controller-colon-notation
- dev-feature/token-path-segment
- dev-fix/format-datetime-filter
- dev-fix/serializer-denormalization
- dev-feature/guest-checkout
- dev-feature/github-actions-ci
- dev-fix/reference-generator-use-entity-table
- dev-fix/order-element-null-description
- dev-main
This package is auto-updated.
Last update: 2026-05-07 22:36:26 UTC
README
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
- Requirements
- Installation
- Quick Start
- Architecture
- Differences from the Original
- REST API Security
- Catalog — products, categories, providers, pool
- Basket — cart entities, lifecycle, transformer to Order
- Customers — customer entities, addresses, guest checkouts
- Pricing & Currency — currency, VAT, order totals
- Payments — provider abstraction, pool, selector, transactions, payment splits
- Stripe Payment — Checkout / Embedded modes, webhooks
- Delivery — carrier abstraction, free-shipping threshold
- Orders & Checkout — pending-order lifecycle, idempotency, cancellation, expiring abandoned orders
- Invoices — invoice generation, credit notes, voucher-sale variants
- Discounts & Coupons — coupon state machine, reservation, discount allocation on partial returns
- Vouchers — balance model, ledger, card-first refund split, sellable gift vouchers
- Returns & Refunds — workflow, refund flow, credit notes
- Upgrading — per-version BREAKING changes and migration steps
- Credits
- License
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_keyin config - Load
https://js.stripe.com/v3/in your template (Stripe CDN, PCI requirement) - Call
$stripePayment->getPaymentIntentClientSecret($order)in your controller - Pass
client_secretto your Twig template - See
Payment/stripe_embedded.html.twigfor 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.
- Stripe Dashboard > Developers > Webhooks.
- Add endpoint URL:
https://your-domain.com/<your-prefix>/stripe/webhook. - Subscribe to events:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcheckout.session.expiredpayment_intent.succeededpayment_intent.payment_failedcharge.refunded— full-refund unwind handler.charge.refund.updated— async-pending refunds (SEPA, Klarna) settling.
- Copy the signing secret to
STRIPE_WEBHOOK_SECRET. - 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 ofsymfony/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— whentrue(default),RefundAmountCalculatoruses the allocation-aware policy branches. Set tofalseto 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_policy—lenient(canonical: per-elementmax(0, unit_price_inc − allocated_per_unit) × return_qty) orstrict(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 thenew_allocation_enabled: falseguard. 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::showfor users with the Sonata-AdminEDITACL.
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\DiscountAllocatorisfinal. To swap allocators, alias your implementation ontoSonata\Component\Interfaces\Discount\DiscountAllocatorInterface:# config/services.yaml Sonata\Component\Interfaces\Discount\DiscountAllocatorInterface: alias: App\Discount\MyCustomAllocator
Sonata\Component\Interfaces\Discount\OrderDiscountManagerInterface::getDiscountsForOrder(int $orderId): arrayis the canonical entry to loadOrderDiscountrows; 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
Couponrow withisVoucher = true, then minted asCouponCoderows. Always available. - Sellable gift vouchers —
VoucherProductpurchased through the shop; on payment the bundle automatically issues a CouponCode and writes a liability ledger row. Gated byvoucher.sellable(defaultfalse).
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:
reloadBalancewrites adisable_max_cyclesledger row (delta 0).- If
auto_disable_on_max_cycles = true(default), parentCoupon.enabledflips tofalsewith a Sonata audit-log entry. - The CouponCode transitions to
STATE_DISABLED(terminal). Further refunds against it writeoverflow_escalationrows 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.succeededwebhook, 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::validaterejects withvoucher.applicable.not_to_voucher_purchase. - Disabled / cancelled —
CouponValidatorshort-circuits onSTATE_DISABLED(cycle-cap auto-disable, see balance model) andSTATE_CANCELLED(sellable-voucher refund_sale) withvoucher.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 ledgerrefund_salerow. - Partially redeemed — return rejected with
voucher.already_redeemed_not_returnable. Operator override available viavoucher.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:
- Creates a refund Transaction.
- Resolves the payment provider via
PaymentSelector. - Computes the refund amount via
RefundAmountCalculator(allocation-aware — see Discounts & Coupons). - Calls
$payment->refund(...)with a stable idempotency key (refund-{ReturnRequest.reference}). - For voucher-paid orders, runs
VoucherRefundAllocatorfirst to compute the card-first split (see Vouchers > Refunds with vouchers). - Updates Order
paymentStatustoPARTIALLY_REFUNDEDorREFUNDED. - 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
creditNoteForself-reference. - Element prices,
totalExcl, andtotalIncare negated. - VAT rate is preserved (not negated).
- Reference format:
CN-YYMMDD000001(configurable viaReferenceGeneratorInterface::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 noOrder.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
- Thomas Rabaix and the Sonata Project contributors for the original ecommerce bundle
- Christopher Schwarz for the port to Symfony 7 / Sonata Admin 4
License
This project is licensed under the MIT License — see the LICENSE file for details.