oxid-esales / payment-base
Provider-agnostic payment component with smart-contract architecture and shared admin Payment tab
Package info
github.com/OXID-eSales/payment-base
Type:oxideshop-module
pkg:composer/oxid-esales/payment-base
Requires
- php: ^8.2
- ext-json: *
- doctrine/dbal: ^2.13 || ^3.0
- doctrine/migrations: ^3.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- symfony/console: ^6.4 || ^7.0
- symfony/filesystem: ^6.4 || ^7.0
Requires (Dev)
- phpmd/phpmd: ^2.13
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- shipmonk/dead-code-detector: ^1.4
- squizlabs/php_codesniffer: ^3.7
- symfony/yaml: ^6.4 || ^7.0
- twig/twig: ^3.0
Suggests
- ext-apcu: Enables the APCu-backed rate limiter for the MCP HTTP endpoints
Provides
None
Conflicts
- oxid-esales/oxideshop-ce: <7.4
Replaces
None
This package is auto-updated.
Last update: 2026-11-09 12:17:48 UTC
README
Provider-agnostic payment foundation with Smart-Contract Architecture for e-commerce platforms.
Overview
Payment Base is a universal, event-driven payment library that enables seamless integration of multiple payment providers (Stripe, PayPal, Unzer, Adyen, etc.) with 95% code reusability. Built on the Smart-Contract Architecture pattern where "Place Order" creates a contract, not an order—orders are created only when conditions are fulfilled.
Key Benefits
- 95% Reusability - Provider-agnostic core, only ~5% provider-specific code needed
- 70% Faster Integration - New payment providers in 35-50 hours vs 120-160 hours
- Event-Driven - All business logic triggered via PSR-14 domain events
- Smart Contracts - Two-step authorization with condition-based fulfillment
- Type-Safe - PHP 8.2+ with strict typing and PHPStan level 6 compliance
Installation
composer require oxid-esales/payment-base
Migrations
The module ships its migrations in migration/ and registers them through
migration/migrations.yml. They are run by the shop's standard migration flow, which picks up
every module suite:
vendor/bin/oe-eshop-db_migrate migrations:migrate
oxid-esales/oxideshop-doctrine-migration-wrapper provides that command and is part of the
OXID eShop installation, so it does not have to be required separately.
Run them on every composer install / composer update
The module ships vendor/bin/oe-payment-base-migrate, which runs only this module's suite
(oe_payment_base). Composer runs scripts of the project's own composer.json only, never those of
a dependency, so add it there:
"scripts": { "post-install-cmd": ["oe-payment-base-migrate"], "post-update-cmd": ["oe-payment-base-migrate"] }
Keep any commands that are already listed (append to the arrays). The command skips itself while
the shop is not set up yet (no migration wrapper, or source/config.inc.php still holds the
<dbHost> placeholder), so a first composer install works. Once the shop is set up, a failing
migration fails the composer run. The module must be installed (oe:module:install); it does not
have to be active.
Requirements
- OXID eShop 7.4 or 7.5
- PHP 8.2+
- PSR-3 Logger (psr/log ^1.0 || ^2.0 || ^3.0)
- Doctrine DBAL ^2.13 || ^3.0
- Doctrine Migrations ^3.0
- Symfony Console and Filesystem ^6.4 || ^7.0
Smart-Contract Architecture
Traditional payment flow creates orders immediately, then handles payment failures with complex rollback logic. The Smart-Contract pattern reverses this:
Traditional: User clicks "Order" → Order created → Payment → Update order on failure
Smart-Contract: User clicks "Order" → Contract created → Conditions resolved → Order created
Contract Lifecycle
DRAFT → PENDING → READY_TO_COMMIT → COMMITTED → FULFILLED
↘ CANCELLED / EXPIRED / FAILED
States:
DRAFT- Contract initialized, basket capturedPENDING- Awaiting conditions (payment auth, fraud check, stock)READY_TO_COMMIT- All conditions fulfilledCOMMITTED- Order created in shop systemFULFILLED- Payment captured, process complete
Contract Conditions
Contracts track multiple conditions that must be satisfied:
$contract->addCondition(new ContractCondition('payment_authorized')); $contract->addCondition(new ContractCondition('fraud_check_passed')); $contract->addCondition(new ContractCondition('stock_reserved')); // Conditions fulfilled asynchronously via webhooks/events $contract->fulfillCondition('payment_authorized');
Architecture
┌─────────────────────────────────────────────────────────────┐
│ CONTROLLER LAYER - Thin controllers, emit events only │
└────────────────────────────┬────────────────────────────────┘
│ emits
┌────────────────────────────▼────────────────────────────────┐
│ EVENT SYSTEM - Domain Events (PSR-14 compatible) │
└────────────────────────────┬────────────────────────────────┘
│ triggers
┌────────────────────────────▼────────────────────────────────┐
│ SERVICE LAYER - ContractService, PaymentService │
└────────────────────────────┬────────────────────────────────┘
│ uses
┌────────────────────────────▼────────────────────────────────┐
│ ADAPTER LAYER - PaymentHandlerInterface, ShopAdapterInterface│
└────────────────────────────┬────────────────────────────────┘
│ persists
┌────────────────────────────▼────────────────────────────────┐
│ REPOSITORY LAYER - ContractRepository, TransactionRepository│
└─────────────────────────────────────────────────────────────┘
Directory Structure
src/
├── Adapter/ # Payment provider abstraction
│ ├── PaymentHandlerInterface.php
│ ├── Dto/ # Data transfer objects
│ └── Response/ # Standardized adapter responses
├── Contract/ # Smart-contract domain
│ ├── PaymentContract.php # Aggregate root
│ ├── ContractCondition.php # Condition entity
│ └── BasketSnapshot.php # Immutable basket value object
├── Controller/ # Base controllers
│ └── Webhook/ # Webhook handling base classes
├── EventSystem/ # Event-driven architecture
│ ├── Event/ # Domain events
│ │ ├── Contract/ # Contract lifecycle events
│ │ └── Payment/ # Payment lifecycle events
│ ├── EventDispatcher.php
│ └── EventListenerProvider.php
├── GraphQL/ # Headless API glue for graphql-base/-storefront (Sprint 15): namespace mapper,
│ # PAYMENT_CHECKOUT right, shared Checkout*Result types, placeOrder guard
├── Checkout/Headless/ # Provider-agnostic headless checkout: start / return / cancel on the
│ # providers' tagged payment handlers and return resolvers
├── Middleware/ # Request/response middleware
├── Model/ # Domain models
├── Order/ # Order integration
├── Repository/ # Data access layer
├── Service/ # Business logic services
│ ├── ContractService.php
│ ├── CheckoutOrchestrator.php
│ └── Idempotency/ # Duplicate payment prevention
├── Traits/ # Reusable traits
├── Transaction/ # Transaction tracking
└── Webhook/ # Webhook processing
Usage
Implementing a Payment Provider
Implement PaymentHandlerInterface (starts and confirms a payment for your provider) and tag it as a payment
handler in your module's services.yaml. The provider SDK calls stay in your module; payment-base only sees the
handler, the events it emits and the contract it writes. Shop access (translations, shop URL, shop id) is
payment-base's own ShopAdapterInterface, already bound — do not bind it yourself.
Creating a Payment Contract
use OxidEsales\PaymentBase\Contract\PaymentContract; use OxidEsales\PaymentBase\Contract\BasketSnapshot; // Capture basket state immutably $basketSnapshot = BasketSnapshot::fromBasket($basket); // Create contract $contract = new PaymentContract( shopId: $shopId, userId: $userId, basketSnapshot: $basketSnapshot ); // Add conditions that must be fulfilled $contract->addCondition('payment_authorized'); $contract->addCondition('fraud_check_passed'); // Persist contract $contractRepository->save($contract);
Handling Webhooks
use OxidEsales\PaymentBase\Webhook\AbstractWebhookHandler; class StripeWebhookHandler extends AbstractWebhookHandler { protected function getTransactionIdFromPayload(array $payload): string { return $payload['data']['object']['id']; } protected function getEventTypeFromPayload(array $payload): string { return $payload['type']; } protected function processPaymentSucceeded(array $payload): void { $contract = $this->contractRepository->findByProviderReference( $payload['data']['object']['id'] ); $contract->fulfillCondition('payment_authorized'); $this->contractRepository->save($contract); $this->eventDispatcher->dispatch( new PaymentAuthorizedEvent($contract) ); } }
Event Handling
use OxidEsales\PaymentBase\EventSystem\Event\Contract\ContractReadyToCommitEvent; use OxidEsales\PaymentBase\EventSystem\SubscriberInterface; class OrderCreationSubscriber implements SubscriberInterface { public static function getSubscribedEvents(): array { return [ ContractReadyToCommitEvent::class => 'onContractReady', ]; } public function onContractReady(ContractReadyToCommitEvent $event): void { $contract = $event->getContract(); // Create order from contract's basket snapshot $order = $this->orderService->createFromContract($contract); $contract->commit($order->getId()); $this->contractRepository->save($contract); } }
Headless checkout (GraphQL Storefront, agents)
The contract-first checkout is available without a Twig page: the Stripe, Mollie and PayPal modules ship
<provider>CheckoutStart / CheckoutReturn / CheckoutCancel mutations over payment-base's HeadlessCheckoutService,
and the provider's webhook ends the order. The payment method is chosen by which start mutation the client calls
(stripeCheckoutStart, mollieCheckoutStart with an optional Mollie method, paypalCheckoutStart); the core
placeOrder is refused for these payments and names the mutation to use. Details, the shared result types, error codes
and the CLI scripts of the three modules: docs/graphql-headless-checkout.md.
Admin payment API
With oxid-admin-graphql installed (optional), payment-base adds the payment of an order to the admin API: the rights
ADMIN_PAYMENT_*, AdminOrder.paymentContract, and adminPaymentCapture / adminPaymentRefund / adminPaymentVoid in front
of the provider modules' own capture, refund and cancel code, plus the answers to the admin API's order and payment-ownership
events. Without it nothing changes. docs/admin-payment-api.md.
Domain Events
Contract Events
| Event | Trigger |
|---|---|
ContractCreatedEvent |
New contract initialized |
ContractTransitionedToPendingEvent |
Contract submitted for processing |
ContractConditionFulfilledEvent |
A condition was satisfied |
ContractReadyToCommitEvent |
All conditions fulfilled |
ContractCommittedEvent |
Order created from contract |
ContractFulfilledEvent |
Payment captured, process complete |
ContractCancelledEvent |
Contract cancelled by user/system |
ContractExpiredEvent |
Contract timed out |
Payment Events
| Event | Trigger |
|---|---|
PaymentInitiatedEvent |
User starts checkout |
PaymentAuthorizedEvent |
Funds reserved |
PaymentCapturedEvent |
Funds transferred |
PaymentRefundedEvent |
Refund processed |
PaymentFailedEvent |
Payment declined |
WebhookReceivedEvent |
Provider webhook received |
Development
Running Tests
# All tests vendor/bin/phpunit # Unit tests only vendor/bin/phpunit --testsuite Unit # Integration tests only vendor/bin/phpunit --testsuite Integration # Single test file vendor/bin/phpunit tests/Unit/Contract/PaymentContractTest.php # Single test method vendor/bin/phpunit --filter testContractTransitionsToPending
Static Analysis
vendor/bin/phpstan analyse
Supported Providers
The Payment Base is designed to support any payment provider with REST/SOAP API and webhooks:
- Stripe
- PayPal
- Amazon Pay
- Unzer
- TeleCash
- Adyen
- Mollie
- Klarna
- Braintree
- Square
License
proprietary - See LICENSE for details.
Credits
Developed by OXID eSales AG with AI assistance from Claude (Anthropic).