PHPackages                             smartlabs/sonata-ecommerce - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. smartlabs/sonata-ecommerce

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

smartlabs/sonata-ecommerce
==========================

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

4.4.0(3mo ago)11[24 issues](https://github.com/smartlabsAT/sonata-project-ecommerce/issues)MITPHPPHP ^8.3CI passing

Since Feb 20Pushed 3mo agoCompare

[ Source](https://github.com/smartlabsAT/sonata-project-ecommerce)[ Packagist](https://packagist.org/packages/smartlabs/sonata-ecommerce)[ Docs](https://smartlabs.at)[ RSS](/packages/smartlabs-sonata-ecommerce/feed)WikiDiscussions 4.x Synced 2mo ago

READMEChangelog (10)Dependencies (141)Versions (91)Used By (0)

Sonata eCommerce Bundle
=======================

[](#sonata-ecommerce-bundle)

[![Latest Stable Version](https://camo.githubusercontent.com/1c0a9d297c8ce28d7e1f423e450b739b2e3ea7f1d445838a47840f39a20f3805/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f736d6172746c6162732f736f6e6174612d65636f6d6d657263652e737667)](https://packagist.org/packages/smartlabs/sonata-ecommerce)[![License](https://camo.githubusercontent.com/cf9dfb7190af4963c31ac15c6156a1004144e15f4a89cccd19147a275c2894b6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f736d6172746c6162732f736f6e6174612d65636f6d6d657263652e737667)](https://packagist.org/packages/smartlabs/sonata-ecommerce)

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
-----------------

[](#table-of-contents)

- [Background](#background)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Architecture](#architecture)
- [Differences from the Original](#differences-from-the-original)
- [REST API Security](#rest-api-security)
- [Catalog](#catalog) — products, categories, providers, pool
- [Basket](#basket) — cart entities, lifecycle, transformer to Order
- [Customers](#customers) — customer entities, addresses, guest checkouts
- [Pricing &amp; Currency](#pricing--currency) — currency, VAT, order totals
- [Payments](#payments) — provider abstraction, pool, selector, transactions, payment splits
- [Stripe Payment](#stripe-payment) — Checkout / Embedded modes, webhooks
- [Delivery](#delivery) — carrier abstraction, free-shipping threshold
- [Orders &amp; Checkout](#orders--checkout) — pending-order lifecycle, idempotency, cancellation, expiring abandoned orders
- [Invoices](#invoices) — invoice generation, credit notes, voucher-sale variants
- [Discounts &amp; Coupons](#discounts--coupons) — coupon state machine, reservation, discount allocation on partial returns
- [Vouchers](#vouchers) — balance model, ledger, card-first refund split, sellable gift vouchers
- [Returns &amp; Refunds](#returns--refunds) — workflow, refund flow, credit notes
- [Upgrading](#upgrading) — per-version BREAKING changes and migration steps
- [Credits](#credits)
- [License](#license)

Background
----------

[](#background)

This bundle is a maintained port of the archived [sonata-project/ecommerce](https://github.com/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
------------

[](#requirements)

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

Installation
------------

[](#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
-----------

[](#quick-start)

### 1. Create your entities

[](#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

[](#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

[](#3-create-the-database-schema)

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

Architecture
------------

[](#architecture)

### Sub-Bundles

[](#sub-bundles)

BundleNamespacePurposeSonataProductBundle`Sonata\ProductBundle`Products, variants, categories, collectionsSonataBasketBundle`Sonata\BasketBundle`Shopping cartSonataOrderBundle`Sonata\OrderBundle`Orders, checkout, pending-order lifecycleSonataInvoiceBundle`Sonata\InvoiceBundle`Invoices, credit notesSonataCustomerBundle`Sonata\CustomerBundle`Customers, addressesSonataPaymentBundle`Sonata\PaymentBundle`Payments, transactions, payment splitsSonataDeliveryBundle`Sonata\DeliveryBundle`Delivery methods, free-shipping thresholdSonataPriceBundle`Sonata\PriceBundle`Price calculationSonataDiscountBundle`Sonata\DiscountBundle`Discount rules, coupon codes, vouchers, balance ledgerSonataReturnBundle`Sonata\ReturnBundle`Returns, refunds, refund allocator### Shared Component

[](#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
-----------------------------

[](#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

[](#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](https://github.com/sonata-project/form-extensions/issues/275)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

[](#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

[](#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
-----------------

[](#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
-------

[](#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

[](#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

[](#entities)

EntityInterfaceBase classProduct`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

[](#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..provider`.

### Pool

[](#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

[](#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

[](#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

[](#generator-command)

`sonata:product:generate ` 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
------

[](#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

[](#configuration-1)

```
# 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-model)

EntityInterfaceBase 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 `BasketElement`s 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

[](#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

[](#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

[](#events)

ConstantChannelFires 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

[](#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:

ConstantChannel`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

[](#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 &amp; Checkout](#orders--checkout) for the full lifecycle.

Customers
---------

[](#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

[](#configuration-2)

```
# 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

[](#entities-1)

EntityInterfaceBase classCustomer`CustomerInterface``BaseCustomer`Address`AddressInterface``BaseAddress`Address types — `Component\Interfaces\Customer\AddressInterface`:

ConstantValuePurpose`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

[](#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 &amp; Refunds](#returns--refunds) for the same token used in the return flow.

### Customer-to-Order linking

[](#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 &amp; Currency
----------------------

[](#pricing--currency)

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

### Configuration

[](#configuration-3)

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

### Currency

[](#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

[](#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

[](#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
--------

[](#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

[](#configuration-4)

```
# 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//callback` without bouncing through the public hostname.

### Provider abstraction

[](#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 &amp; Refunds](#returns--refunds) and [Vouchers](#vouchers).

### Pool and selector

[](#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

[](#transactions)

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

ConstantValueMeaning`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

[](#events-1)

ConstantChannelFires 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

[](#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

[](#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 &lt; 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-payment)

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

### Installation

[](#installation-1)

```
composer require stripe/stripe-php
```

### Configuration

[](#configuration-5)

```
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)

[](#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)

[](#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

[](#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

[](#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 &gt; Developers &gt; Webhooks.
2. Add endpoint URL: `https://your-domain.com//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//stripe/webhook`.

### Test vs Live Mode

[](#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
--------

[](#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

[](#configuration-6)

```
# 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

[](#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-and-selector-1)

```
$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

[](#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 &amp; Checkout
---------------------

[](#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

[](#configuration-7)

```
# 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

[](#entities-2)

EntityInterfaceBase classOrder`OrderInterface``BaseOrder`OrderElement`OrderElementInterface``BaseOrderElement`Order status (`Component\Interfaces\Order\OrderInterface`):

ConstantValueMeaning`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)

[](#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

```

StateMeaning`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

[](#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

[](#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`:

ReasonWhen`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

[](#events-2)

ConstantChannelFires 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

[](#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

[](#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

[](#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
--------

[](#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

[](#configuration-8)

```
# 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

[](#entities-3)

EntityInterfaceBase classInvoice`InvoiceInterface``BaseInvoice`InvoiceElement`InvoiceElementInterface``BaseInvoiceElement`Invoice status (`Component\Interfaces\Invoice\InvoiceInterface`):

ConstantValueMeaning`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

[](#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

[](#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:

ConstantChannel`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

[](#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`.

ConstantChannel`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

[](#voucher-sale-invoice-variants)

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

ColumnMeaning`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 &gt; Sellable gift vouchers &gt; Liability accounting](#liability-accounting) for the full flow.

Discounts &amp; Coupons
-----------------------

[](#discounts--coupons)

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

### Coupon state machine

[](#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](#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.

TransitionMethodTriggered byAVAILABLE → RESERVED`CouponReservationService::reserve()``OrderDiscountManager::createFromBasketLine` at sendbank or re-snapshotRESERVED → 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-disableAVAILABLE → CANCELLED`VoucherSaleRefundListener`Sellable-voucher return (`refund_sale` ledger event)#### Atomicity

[](#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

[](#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

[](#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

[](#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:

ModeRefundLegacy`2 × €100 = €200` (over-refund)Allocation-aware (default)`2 × (€100 − €10) = €180`#### Configuration

[](#configuration-9)

```
# 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_policy` — `lenient` (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

[](#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

[](#re-allocate-a-single-order)

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

- `bin/console sonata:discount:reallocate-order ` — 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

[](#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

[](#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
--------

[](#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 vouchers** — `VoucherProduct` 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

[](#configuration-10)

All keys live under `sonata_discount.voucher.*`:

KeyTypeDefaultEffect`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

[](#balance-model)

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

ColumnNotes`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

[](#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`):

ReasonDeltaWhen`consume`negativeCode redeemed at `PaymentEvents::CONFIRMATION`.`reload_refund`positiveRefund credited back via `VoucherRefundAllocator → reloadBalance`.`release_cancel`zeroPre-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`positiveManual admin credit.`admin_adjust`signedManual admin correction.`disable_max_cycles`zeroPaired with auto-disable when `refund_cycle_count > max_refund_cycles_per_code`.`overflow_escalation`zeroReload suppressed (past cap or past grace) — surfaces in the admin overflow queue.`expire`negative, floor 0Post-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

[](#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

[](#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](#webhook-setup).

#### Grace period

[](#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

[](#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

[](#listener-priority-on-order_paid)

Two listeners on `OrderEvents::ORDER_PAID`:

PriorityListenerReaction`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 &lt; 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

[](#zero-amount-orders)

When a voucher (or stacked discounts) covers the whole basket, `Order.total_inc 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 &gt; Refunds with 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

[](#events-3)

ConstantChannelFires 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

[](#credit-notes-1)

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
---------

[](#upgrading)

### From 4.3.x to 4.4.0

[](#from-43x-to-440)

#### BREAKING — `PaymentHandlerInterface::handleZeroAmountOrder`

[](#breaking--paymenthandlerinterfacehandlezeroamountorder)

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](#zero-amount-orders) for the contract.

#### Database migrations (consuming app)

[](#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):

MigrationPurpose`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 &gt; 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)

[](#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

[](#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](#webhook-setup) for the full event list.

#### Configuration

[](#configuration-12)

The new `sonata_discount.refund.*` and `sonata_discount.voucher.*` keys all default to safe values. Review the [Discounts &amp; Coupons](#discounts--coupons) and [Vouchers](#vouchers) sections for the full table; set `voucher.sellable: true` only after the [pre-release checklist](#pre-release-checklist) is green.

#### Rollback

[](#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
-------

[](#credits)

- [Thomas Rabaix](https://github.com/rande) and the [Sonata Project](https://github.com/sonata-project) contributors for the original ecommerce bundle
- [Christopher Schwarz](https://github.com/smartlabsAT) for the port to Symfony 7 / Sonata Admin 4

License
-------

[](#license)

This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance60

Regular maintenance activity

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity66

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~7 days

Recently: every ~15 days

Total

11

Last Release

106d ago

PHP version history (2 changes)4.0.0PHP ^8.2

4.0.3PHP ^8.3

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/47238451?v=4)[Christopher Schwarz](/maintainers/smartlabsAT)[@smartlabsAT](https://github.com/smartlabsAT)

---

Top Contributors

[![smartlabsAT](https://avatars.githubusercontent.com/u/47238451?v=4)](https://github.com/smartlabsAT "smartlabsAT (350 commits)")

---

Tags

doctrineecommercepaymentsphpshopping-cartsonatasonata-adminsymfonysymfony-bundlesymfonypaymentshopecommercecartproductsonatarefundorderreturn

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/smartlabs-sonata-ecommerce/health.svg)

```
[![Health](https://phpackages.com/badges/smartlabs-sonata-ecommerce/health.svg)](https://phpackages.com/packages/smartlabs-sonata-ecommerce)
```

###  Alternatives

[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

595.8M676](/packages/shopware-core)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M430](/packages/easycorp-easyadmin-bundle)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M779](/packages/sylius-sylius)[chameleon-system/chameleon-base

The Chameleon System core.

1029.4k6](/packages/chameleon-system-chameleon-base)[contao/core-bundle

Contao Open Source CMS

1301.7M3.1k](/packages/contao-core-bundle)[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.9M536](/packages/pimcore-pimcore)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
