contenir / contenir-commerce
Commerce domain for Contenir sites on contenir-db-model 2: orders and their lifecycle, artworks, GST-inclusive money, artist enquiries, the email log, and Stripe checkout and refunds.
Requires
- php: ~8.3.0 || ~8.4.0 || ~8.5.0
- contenir/contenir-db-model: ^2.0@RC
- psr/clock: ^1.0
- psr/container: ^1.1 || ^2.0
- stripe/stripe-php: ^22.0
Requires (Dev)
- infection/infection: ^0.34.1
- laminas/laminas-servicemanager: ^3.22 || ^4.0
- php-db/phpdb-qa-tools: 0.1.x-dev
- php-db/phpdb-sqlite: ^0.2.0 || 0.3.x-dev
- phpunit/phpunit: ^11.5.50
Suggests
None
Provides
None
Conflicts
None
Replaces
- contenir/commerce: v2.0.0-RC2
This package is auto-updated.
Last update: 2026-10-07 03:10:06 UTC
README
Formerly contenir/commerce; the old package is abandoned in favour of this one.
The commerce domain for Contenir gallery sites, built on contenir-db-model 2:
- orders and their lifecycle (
CheckoutService,CompletionService,FulfilmentService, and theOrderManagerfaçade over them;OrderStatus): pending, paid, awaiting pickup, collected, refunded, cancelled, with every transition enforced; - artworks and retail products, with availability checked when an order is created and when checkout begins, prices checked against the stored artwork, and each work claimed atomically when payment completes, so it sells once;
- tax-inclusive money in integer cents (
Money,TaxRate), with exact rounding at any rate; - artist enquiries and their uploaded files, and the transactional email log;
- Stripe hosted Checkout, session expiry and refunds behind
PaymentGatewayInterface, with every Stripe error surfaced asPaymentFailedException; - extensible entities: abstract bases a site extends with columns of its own, selected by configuration.
It ships a ConfigProvider (Mezzio and other PSR-11 containers) and a Module (laminas-mvc), and has no MVC or
Mezzio plumbing of its own. Version 2.0 is not compatible with 0.2; see UPGRADE-2.0.md.
Requirements
- PHP 8.3, 8.4 or 8.5
- contenir/contenir-db-model 2.x (on php-db/phpdb 0.6, with a platform package such as
php-db/phpdb-mysql) - stripe/stripe-php 22.x
- Any PSR-11 container, for the optional factories
Install
2.0 is a release candidate (2.0.0-RC2): contenir-db-model 2 is itself at RC and builds on php-db/phpdb 0.6, which
has no stable release yet. Composer only honours stability flags in the root package, so a site needs these in its
own composer.json:
{
"require": {
"contenir/contenir-commerce": "^2.0@RC",
"contenir/contenir-db-model": "^2.0@RC",
"php-db/phpdb": "0.6.x-dev@dev"
}
}
Alternatively set "minimum-stability": "dev" with "prefer-stable": true in the site's composer.json and
require contenir/contenir-commerce normally.
With laminas-component-installer the
Contenir\Commerce\ConfigProvider (Mezzio) or the Contenir\Commerce module (laminas-mvc) is added to your
configuration automatically. contenir-db-model's own ConfigProvider or module supplies the EntityManager.
Quick start
use Contenir\Commerce\Money\Money; use Contenir\Commerce\Order\CustomerDetails; use Contenir\Commerce\Order\OrderManager; use Contenir\Commerce\Order\PurchaseItem; $orders = $container->get(OrderManager::class); // Build each item from the artwork row on the server. A price that differs from the stored artwork's is refused // with PurchaseItemMismatchException. $order = $orders->createPendingOrder( [new PurchaseItem($artwork->artworkId, 'Headland, Dawn', $artwork->getPrice(), 'June Hollis')], new CustomerDetails('Avery Buyer', 'avery@example.test'), ); $session = $orders->beginCheckout( $order, 'https://www.example.com/thank-you?session_id={CHECKOUT_SESSION_ID}', 'https://www.example.com/cart', ); // Redirect the buyer to $session->url. // Later, from the Stripe webhook and the thank-you page (safe to repeat): $result = $orders->completeFromCheckoutSession($sessionId); $result->outcome; // CompletionOutcome::Completed, AlreadyCompleted, NotPaid, RefundedRace or RefundedCancelled // In the CMS: cancelling a pending order also expires its Stripe session. $orders->cancelOrder($order);
A class that needs only one part of the lifecycle can inject CheckoutService, CompletionService or
FulfilmentService instead; OrderManager delegates to them.
$price = Money::fromCents(185_000); $price->gstComponent()->amount; // 16818 $price->format(); // "$1,850.00"
Configuration
The Stripe secret key, normally in an untracked local config file, and optional commerce settings:
return [ 'stripe' => [ 'secret_key' => 'sk_live_...', ], 'contenir_commerce' => [ 'order_reference_prefix' => 'LR', // LR-2026-0001 'currency' => 'AUD', 'tax_rate' => 10, // percent, included in prices 'tax_label' => 'GST', 'artwork_entity' => App\Entity\Artwork::class, // and the other *_entity keys ], ];
The values shown are the defaults (apart from artwork_entity); invalid ones throw ConfigurationException.
Without a Stripe key the container still builds: PaymentGatewayInterface resolves to UnconfiguredGateway, which
throws PaymentFailedException as soon as money would move. See docs/configuration.md and,
for columns of your own, docs/entities.md.
Public API
| Class | Purpose |
|---|---|
Order\OrderManager |
Create, check out, complete, expire, pick up, collect, refund and cancel orders (a façade) |
Order\CheckoutService, Order\CompletionService, Order\FulfilmentService |
The same steps, split by who takes them |
Order\OrderStatus |
The lifecycle and its allowed transitions |
Order\CompletionOutcome, Order\CompletionResult |
What completing a checkout session did |
Order\PurchaseItem, Order\CustomerDetails |
Inputs to createPendingOrder() |
Money\Money, Money\TaxRate |
Tax-inclusive amounts in integer cents, and tax rates in parts per million |
Config\CommerceSettings |
The reference prefix, currency, tax rate and tax label |
Artwork\ArtworkStatus, Artwork\ItemType, Enquiry\EnquiryStatus |
Stored states and their labels |
Model\Entity\Abstract*Entity, Model\Entity\*Entity |
Attribute-mapped rows (artwork, order, order item, artist enquiry, enquiry file, email log): abstract bases and their final defaults |
Model\Repository\*Repository |
Typed finders for each entity; ArtworkRepository::claim() |
Payment\PaymentGatewayInterface |
The payment provider: checkout sessions, their expiry, and refunds |
Payment\StripeGateway, Payment\UnconfiguredGateway |
The shipped gateways |
Payment\CheckoutRequest, Payment\CheckoutLineItem, Payment\CheckoutSession, Payment\RefundResult |
Gateway values |
Exception\* |
Every exception implements Exception\ExceptionInterface |
ConfigProvider, Module, Order\Factory\*, Payment\Factory\*, Config\Factory\*, Model\Repository\Factory\* |
Container wiring |
Every concrete class is final. The extension points are PaymentGatewayInterface and the Abstract*Entity
bases. Time is always read through the
PSR-20 ClockInterface (Clock\SystemClock by default).
The docs folder covers each area:
- Orders and the lifecycle
- Money and tax
- Payments and Stripe
- Artworks
- Artist enquiries and the email log
- Configuration and container
- Entities and your own columns
Development
The QA toolchain is contenir/contenir-qa-tools.
Mago is a standalone binary, installed separately (brew install mago).
composer check # everything below composer cs-check # mago format --check && mago lint composer static-analysis # mago analyze composer test # unit suite: no I/O; Stripe through an offline HTTP client composer test-integration # integration suite: in-memory SQLite, real EntityManager and ServiceManager composer test-coverage # both suites, clover.xml for Codecov composer mutation-test # Infection over both suites (needs Xdebug or PCOV)
No test touches the network: StripeGateway is tested through stripe-php's pluggable HTTP client, and the order
services through a scriptable fake gateway. The concurrency tests interleave two completions over two connections to
one in-memory SQLite database.
License
MIT. See LICENSE.md.