Reinventing Payment: How I Evolved a Domain Model from One Table to DDD
From a single payments table to channels, state machines, and hexagonal architecture in PHP

Every payment system starts the same way: one table, one provider, ship it. Then the second provider arrives. Then retry logic. Then partial refunds. Then you realize the model you built on day one is lying to you.
This article is the story of how that happened to me — and what I built instead.
The result is Payroad: an open-source PHP domain model for payment processing. Not another provider SDK, and not a thin HTTP wrapper. A domain model — Payment, PaymentAttempt, channels, state machines, events — that you can plug into any application and extend for any provider.
Why not Omnipay or Payum?
Omnipay does what it was built for well: it normalizes provider APIs. One interface for a hundred providers, $gateway->purchase([...]) works the same way for Stripe and PayPal. But Omnipay stops at the transport layer — after calling purchase() you get a Response and build the rest yourself. Retry logic, state machines, domain events — every project reinvents everything above that layer.
Payum goes further: there's a payment concept, storage, UI components for payment flows. But the target audience is Symfony projects, and the architecture reflects that: concrete ORMs, bundles, action classes as external state handlers. Outside the Symfony ecosystem it feels like fighting the library.
Both solve the problem of connecting providers. Neither starts with the question of what a payment domain model should look like.
I wanted to try exactly that. This article documents the decisions I made and why.
The beginning: one table, one provider
I need to accept money from users. The task looks simple — connect Stripe, get a card token, call charge.
The first implementation takes a day:
CREATE TABLE payments (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
amount INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL, -- 'pending', 'paid', 'failed'
provider_id VARCHAR(255),
created_at TIMESTAMP NOT NULL
);
class PaymentService
{
public function charge(User \(user, int \)amount, string $token): Payment
{
\(result = \)this->stripe->charge(\(amount, \)token);
\(payment = new Payment(\)user->id, $amount, 'USD');
\(payment->status = \)result->status === 'succeeded' ? 'paid' : 'failed';
\(this->db->save(\)payment);
return $payment;
}
}
It works. The product manager is happy.
The problem: a second provider breaks the model
The product manager comes with a new requirement: cryptocurrency payments. Then — P2P transfers. It seems like the same task — just add fields to the table and another if in the webhook handler:
ALTER TABLE payments ADD COLUMN crypto_address VARCHAR(255);
ALTER TABLE payments ADD COLUMN crypto_txhash VARCHAR(255);
ALTER TABLE payments ADD COLUMN confirmations INTEGER;
ALTER TABLE payments ADD COLUMN p2p_account VARCHAR(255);
ALTER TABLE payments ADD COLUMN three_ds_data JSON;
public function handleWebhook(array $payload): void
{
\(payment = \)this->db->findByProviderId($payload['id']);
if ($payment->provider === 'stripe') {
if ($payload['type'] === 'payment_intent.succeeded') {
$payment->status = 'paid';
}
} elseif ($payment->provider === 'nowpayments') {
$payment->confirmations++;
if ($payment->confirmations >= 3) {
$payment->status = 'paid';
}
}
\(this->db->save(\)payment);
}
But it quickly becomes clear this is not "just another if". Stripe returns requires_action for 3DS — the payment is not complete, but not failed either, it needs confirmation. Crypto doesn't respond instantly — a transaction appears in the blockchain after minutes, confirms over hours, may arrive partially. P2P waits for a manual bank transfer — a payment can stay open for a day.
Each provider lives on its own timeline and by its own rules. One table, one paid/failed status — this is a model that works for exactly one synchronous provider. I'm not extending it, I'm breaking it.
And the most painful problem becomes visible later: if a card is declined, the payment gets stuck in failed forever. There's no mechanism to try again — the model simply doesn't account for that scenario.
The solution: separate intent from action
Before designing new tables or classes, it's worth stopping and asking: what is a payment from the user's perspective?
For the user, a payment is the intent to pay a certain amount for a certain thing. This intent doesn't disappear if a card is declined. The user wants to pay — the first attempt just failed. In the current model I've mixed intent and its execution into one object. That's why there's no room for a second attempt.
I separate:
Payment — the intent. Knows the amount, who to pay, why. Doesn't know which provider processed it.
PaymentAttempt — a concrete attempt to fulfill the intent. Knows the provider, the status from it, the transaction ID. Doesn't know why the user needs this.
Payment — intent PaymentAttempt — action Knows amount · who to pay · what for provider · status · transaction ID Doesn't know which provider processed it why the user needs this
One intent — multiple attempts:
In code this is expressed as:
final class Payment
{
private PaymentStatus $status; // PENDING, PROCESSING, SUCCEEDED, FAILED
public function markSucceeded(PaymentAttemptId $byAttempt): void { /* ... */ }
public function markRetryable(): void { /* returns to PENDING → a new attempt can be created */ }
}
abstract class PaymentAttempt
{
private PaymentId $paymentId; // reference to the intent
private string $providerName;
private AttemptStatus $status;
private ?string $providerReference; // transaction ID at the provider
}
markRetryable() is the key method. When an attempt fails, the payment returns to PENDING and the system creates a new attempt. Retry logic is no longer a hack — it's built into the model, because now the model honestly reflects what happens.
This separation is not new. Martin Fowler described a similar structure in Analysis Patterns (1996) in the context of accounting systems: Account records intent and aggregates state, Entry is a concrete event that changes that state. An account doesn't know the details of a ledger entry; the entry doesn't carry the business meaning of the account. An important detail: in accounting you can't edit an entry — you can only create a correcting one. In the payment domain the same logic applies: an attempt cannot be reused, only a new one can be created. This is not a coincidental similarity — both systems model irreversible financial events.
Airwallex described a similar approach to modeling a payment system through DDD: Domain-Driven Design Practice: Modeling Payments System.
Aside: why amount is not an integer?
In the naive model, amount is int $amount and a separate string $currency. Seemingly enough. But adding crypto breaks this immediately: ETH has 18 decimal places, 1 ETH = 10¹⁸ wei. That number doesn't fit in a 64-bit int starting at around 9.2 ETH. Storing amounts as float is even worse — precision loss in arithmetic is guaranteed.
There's a more fundamental problem: int doesn't carry the currency. Nothing in the signature function charge(int $amount, string $currency) prevents you from passing an amount in dollars where cents are expected, or mixing up USD and JPY. JPY has no fractional part at all — one cent for a dollar is already a semantic violation of the Japanese yen.
We move money into a separate value object:
final readonly class Currency
{
public function __construct(
public readonly string $code, // 'USD', 'BTC', 'ETH'
public readonly int $precision, // 2, 8, 18
) {}
}
final readonly class Money
{
public function __construct(
private string \(minorAmount, // in minimal units: cents, satoshi, wei
private Currency \)currency,
) {}
public static function ofDecimal(string \(amount, Currency \)currency): self { /* BCMath */ }
public function add(self $other): self { /* assertSameCurrency */ }
public function subtract(self $other): self { /* assertSameCurrency */ }
}
Money stores the amount in the minimal units of the currency — cents for dollars, satoshi for bitcoin, wei for ether. Arithmetic via BCMath, no floats. Comparing or adding Money with a different currency throws an exception immediately — the error cannot go unnoticed.
Currency doesn't know about any currency registry — precision is passed explicitly at creation. The list of known currencies with their precisions lives in the infrastructure layer and doesn't enter the domain. This matters: the domain must not depend on external lookup tables.
In the PHP world there's an excellent library moneyphp/money — it solves exactly this problem and is widely used in production. Why write your own? But the library has a fundamental limitation: Currency in it is tied to ISO 4217 and an internal registry. Crypto falls outside the model — BTC with 8 digits and ETH with 18 are not accounted for. You can add custom currencies through extensions, but that's already fighting the library rather than using it.
There's also an architectural argument. The domain model is the core of the system. Adding an external dependency to it means taking on its lifecycle: major versions, breaking changes, maintainer decisions about what counts as a valid currency. Money is a few dozen lines with BCMath. Here it's more advantageous to own the code than to depend on a package.
After this, instead of int $amount, string $currency everywhere in domain objects, there's Money $amount — and the compiler ensures only compatible amounts are compared.
The problem: attempts are too different to live in one class
I have an abstract PaymentAttempt — and the question immediately arises: how to store data specific to each provider?
The first impulse — add nullable fields:
abstract class PaymentAttempt
{
private ?string $cryptoAddress = null;
private ?string $cryptoTxHash = null;
private ?int $confirmations = null;
private ?string $savedCardToken = null;
// ...
}
This is the same trap as the table — I've just moved it into an object. A card attempt has no idea about cryptoAddress, and a crypto one has no idea about savedCardToken. The fields exist, but most of them are always null depending on the type.
Fine, then a hierarchy by provider: StripeAttempt, NowPaymentsAttempt. Each class stores only its own fields. Looks cleaner — but another problem emerges: what happens when I switch from Stripe to Adyen?
Stripe and Adyen are both card acquirers. They both support authorization, capture, 3DS, partial capture. Their domain-level logic is identical. If I've tied the domain model to StripeAttempt, I'll have to rewrite the domain just to change infrastructure. This is a violation that makes providers non-replaceable.
I realize: what interests me is not who processes the payment, but how money moves. These are different things.
The solution: channel as a business concept
A card payment is authorization and capture. That's how all card acquirers work, regardless of name. Crypto is a wallet address, waiting for a blockchain transaction, counting confirmations. That's how all crypto gateways work. P2P is transfer credentials and a wait timeout. Cash is a code and a terminal.
These are not provider details — they're the nature of each payment method. I call this a channel.
The channel defines everything else: what data is needed, what states are possible, what operations are allowed. The provider is a replaceable detail inside the channel.
Each channel stores only its own data — without nullable fields from another channel:
class CardAttemptData implements AttemptData
{
public function __construct(
public readonly ?ThreeDSData $threeDSData = null,
public readonly ?string $savedMethodToken = null,
) {}
}
class CryptoAttemptData implements AttemptData
{
public function __construct(
public readonly string $depositAddress,
public readonly string $currency,
public readonly ?string $txHash = null,
public readonly int $confirmations = 0,
) {}
}
And most importantly: the channel makes impossible the operations that don't make sense for it. markAuthorized() exists only on CardPaymentAttempt — card authorization is a card concept, crypto doesn't have it physically. markPartiallyPaid() exists only on crypto — a card is either fully charged or not. If someone tries to call markAuthorized() on a crypto attempt — that's a compile error, not a runtime one.
The problem: channel lifecycles don't match
Channels are separated into classes — data is clean. But statuses are shared across all channels, and here new complexity appears.
A card payment with 3DS goes through authorization before money is charged:
PENDING → AWAITING_CONFIRMATION → PROCESSING → SUCCEEDED
→ AUTHORIZED → PROCESSING → SUCCEEDED
→ CANCELED
A crypto payment doesn't know the concept of "authorization" — it waits for a transaction to appear and may receive money partially:
PENDING → AWAITING_CONFIRMATION → PARTIALLY_PAID → SUCCEEDED
└──→ FAILED
└──→ EXPIRED
If the status is just an enum, nothing in the code prevents transitioning a crypto attempt to AUTHORIZED. Or returning an already successful payment back to PROCESSING. These errors are invisible in static analysis — they show up in data, already in production.
A mechanism is needed that knows which transitions are allowed from which state — and for each channel that mechanism is its own.
The solution: state machine is built into each channel
Each channel carries its own state machine — a separate object with a single responsibility: know the allowed transitions.
final class CardStateMachine implements AttemptStateMachineInterface
{
public function canTransition(AttemptStatus \(from, AttemptStatus \)to): bool
{
return match ($from) {
AttemptStatus::PENDING => in_array($to, [
AttemptStatus::AUTHORIZED,
AttemptStatus::AWAITING_CONFIRMATION,
AttemptStatus::PROCESSING,
AttemptStatus::SUCCEEDED,
AttemptStatus::FAILED,
], true),
AttemptStatus::AUTHORIZED => in_array($to, [
AttemptStatus::PARTIALLY_CAPTURED,
AttemptStatus::PROCESSING,
AttemptStatus::SUCCEEDED,
AttemptStatus::CANCELED,
AttemptStatus::FAILED,
], true),
default => false,
};
}
}
The base class passes every transition through it:
protected function applyTransition(AttemptStatus \(to, string \)providerStatus): void
{
if(!\(this->stateMachine()->canTransition(\)this->status, $to)) {
throw new InvalidTransitionException(\(this->status, \)to);
}
\(this->doTransition(\)to, $providerStatus);
}
An invalid transition throws immediately — data doesn't get silently corrupted. CardStateMachine doesn't know about PARTIALLY_PAID, CryptoStateMachine doesn't know about AUTHORIZED. Each channel guards its own lifecycle.
The problem: one event hides four different situations
Domain events tell the rest of the system that something significant happened. I design events for channel statuses — and here comes the temptation to make one universal event for all cases when the user needs to do something:
new AttemptRequiresUserAction(\(attemptId, \)paymentId, $methodType);
Seems convenient — one handler, one subscription. But look at what's hiding behind that name:
Card: the user needs to complete 3DS authentication on the bank's page — a redirect with a timeout
Crypto: the user needs to send coins to the specified address — instructions with an address and amount
P2P: the user needs to make a bank transfer with the given details — a different set of data and a different context
Cash: the user needs to go to a terminal and pay by code — a physical action
These are four different situations with different data, different instructions, different UI. I call them by one name not because they're the same — but because they're technically similar: all four arise at the AWAITING_CONFIRMATION status. That's a technical similarity, not a semantic one.
The notification handler is forced to do switch ($methodType) internally to figure out what to write to the user. So the difference exists anyway — I've just hidden it inside the handler.
The solution: events live next to their channel
I split events by channel. Each event carries exactly the meaning its handler needs:
Domain/Channel/Card/Event/
AttemptRequiresConfirmation ← 3DS authentication needed
Domain/Channel/Crypto/Event/
AttemptAwaitingPayment ← waiting for coins to be sent
Domain/Channel/Cash/Event/
AttemptAwaitingCashPayment ← waiting for terminal payment
Domain/Channel/P2P/Event/
AttemptAwaitingTransfer ← waiting for bank transfer
Not everything needs to be split though. Terminal events — AttemptSucceeded, AttemptFailed, AttemptExpired — remain universal. The billing system listens to AttemptSucceeded and genuinely doesn't care whether it was card or crypto — money arrived. The notification service subscribes to specific channel events and knows exactly what to tell the user — without switch on methodType.
Split where meaning differs. Unify where meaning is the same.
Refunds: a refund is tied to an attempt, not to a payment
When a user asks for money back, the first instinct is to attach the refund to Payment. Logical: the payment was created at the intent level, the refund also concerns that intent.
But here's the problem: how exactly do you return the money? That depends on the channel through which it came. Refunding a card payment is calling refund at the acquirer with a paymentIntentId. Refunding crypto is a separate transfer to the wallet address. Different APIs, different data, different logic.
So the refund needs to know about the specific attempt — through which channel and provider the money came. The binding to originalAttemptId is not a technical detail, it's a domain requirement:
abstract class Refund
{
private RefundId $id;
private PaymentId $paymentId;
private PaymentAttemptId $originalAttemptId; // ← which attempt
private string $providerName;
private Money $amount;
private RefundStatus $status;
}
Like attempts, refunds are split by channel: CardRefund, CryptoRefund, P2PRefund, CashRefund. Each stores its own data and has its own state machine.
Payment meanwhile doesn't know about refund details — it only tracks a counter:
public function addRefund(RefundId \(refundId, Money \)amount): void
{
\(this->refundedAmount = \)this->refundedAmount->add($amount);
if (\(this->refundedAmount->equals(\)this->amount)) {
$this->status = PaymentStatus::REFUNDED;
} else {
$this->status = PaymentStatus::PARTIALLY_REFUNDED;
}
}
A refund can be partial — multiple returns of different amounts until refundedAmount reaches amount. Payment tracks this regardless of which channel each refund went through.
Saved payment methods
When a user pays with a card for the first time, they enter the card number. If they pay again — it's inconvenient to make them enter details again. The provider can tokenize the card: instead of the card number, a providerToken is stored — an opaque string the provider can use to charge again without CVV or card data.
SavedPaymentMethod is a separate aggregate, not part of Payment and not part of PaymentAttempt. This is intentional: a saved card lives longer than any specific payment. It's tied to the customer, not the transaction:
abstract class SavedPaymentMethod
{
private CustomerId $customerId;
private string $providerName;
private string $providerToken; // token at the provider
private SavedPaymentMethodStatus $status; // ACTIVE, EXPIRED, REMOVED
}
The saved method has its own lifecycle: it can expire (expire()) when the card reaches its expiry date — the provider sends a notification about this. Or the user can remove it (remove()).
A method can only be saved after a successful attempt — the provider returns a token from a confirmed transaction. That's why CardPaymentAttempt has assertCanSaveMethod() which checks that the attempt is in SUCCEEDED status.
Currently SavedPaymentMethod is only implemented for the card channel — it's the only channel where tokenization makes practical sense in the current model.
Model diagram
Object structure
Attempt lifecycles by channel
Card
Crypto
P2P
Cash
How it works in practice: ports and use cases
The domain model is designed — but on its own it does nothing. A layer is needed that orchestrates: loads aggregates, calls the provider, saves changes, publishes events.
Why the domain must not know about the outside world
The first impulse — write directly in the use case: $this->stripeClient->paymentIntents->create(...). It's quick. But then the use case is tightly coupled to the Stripe SDK. Changing the provider means rewriting the use case. Worse: running a test of this use case without a real Stripe is impossible — you need to mock the SDK, and Stripe mocks are notoriously verbose and fragile.
Deeper: if the Stripe SDK gets into the use case, then SDK versioning becomes part of the domain logic lifecycle. A breaking change in the SDK forces you to touch business code.
The solution — separate what we do (orchestration in the use case) and how we do it (details in the adapter). The boundary is expressed through a port interface. The use case depends on the interface, the adapter implements it.
┌─────────────────────────────────────────────────────┐
│ Application Layer │
│ UseCase ──► Port/Repository ──► (DB Adapter) │
│ └──► Port/Provider ──► (Stripe Adapter) │
│ └──► Port/Event ──► (Symfony Messenger) │
├─────────────────────────────────────────────────────┤
│ Domain Layer │
│ Payment, PaymentAttempt, Money — pure PHP │
└─────────────────────────────────────────────────────┘
Domain and application layer — pure PHP with no dependencies. Adapters live outside and implement ports. Use case tests work with fake port implementations, without spinning up a real DB or Stripe.
Repositories: the domain doesn't know how data is stored
If a use case directly uses Doctrine Entity Manager or writes SQL, changing the storage (PostgreSQL → MongoDB, adding cache) pulls along changes to business logic. This is the wrong direction of dependency.
A repository is a port through which domain objects enter storage and come back. The use case says save($payment), knowing nothing about tables:
interface PaymentRepositoryInterface
{
public function nextId(): PaymentId;
public function save(Payment $payment): void;
public function findById(PaymentId $id): ?Payment;
}
interface PaymentAttemptRepositoryInterface
{
public function nextId(): PaymentAttemptId;
public function save(PaymentAttempt $attempt): void;
// Primary lookup path for incoming webhooks
public function findByProviderReference(string \(providerName, string \)reference): ?PaymentAttempt;
}
PaymentAttempt::create() takes an ID as an argument — the aggregate doesn't generate its own ID, the ID comes from outside. This allows passing it to the provider before saving the attempt: the provider records it as an external reference, and the attempt can be found by it in a webhook.
Providers: abstraction over a payment API
A provider is an adapter to an external payment API. Its contract — accept domain objects, call the API, return a populated aggregate:
interface CardProviderInterface
{
public function initiateCardAttempt(
PaymentAttemptId $id,
PaymentId $paymentId,
string $providerName,
Money $amount,
CardAttemptContext $context,
): CardPaymentAttempt;
public function parseWebhook(array \(payload, array \)headers): WebhookResult;
}
The provider creates the aggregate via CardPaymentAttempt::create(...) and returns it with providerReference already set. It doesn't know about repositories and doesn't publish events — that's not its responsibility. It makes one HTTP request and returns the result.
A question arises: card providers have very different capabilities. Stripe supports saved cards. Adyen — partial capture. Braintree — nonce-flow. One large interface with optional methods will again lead to null-returns and throw new NotImplementedException. Instead, additional capabilities are separate interfaces:
interface CapturableCardProviderInterface extends CardProviderInterface
{
public function captureAttempt(...): CaptureResult;
public function voidAttempt(...): VoidResult;
}
interface TokenizingCardProviderInterface extends CardProviderInterface
{
public function initiateAttemptWithSavedMethod(...): CardPaymentAttempt;
public function savePaymentMethod(...): SavedPaymentMethod;
}
The use case checks via instanceof that the specific provider supports what's needed — and only then calls it. A provider that doesn't support capture simply doesn't implement CapturableCardProviderInterface, rather than throwing NotImplementedException at runtime.
Use cases: orchestration without business logic
Business logic lives in aggregates: whether an attempt can be created, how to transition to a new status, what "successful payment" means. The use case doesn't decide this — it only coordinates: load the needed objects, call the needed methods, save the result.
If a condition like "if amount is greater than X then..." appears in a use case, that's a signal the logic ended up in the wrong place.
final class InitiateCardAttemptUseCase
{
public function execute(InitiateCardAttemptCommand $command): CardPaymentAttempt
{
// idempotent retry: if attempt already exists — return it
\(existing = \)this->attempts->findById($command->attemptId);
if ($existing !== null) {
return CardPaymentAttempt::fromAttempt($existing);
}
// load and validate — validation logic inside the aggregate
\(payment = \)this->guard->loadPayment($command->paymentId);
\(this->guard->guardNoActiveAttempt(\)command->paymentId);
// provider calls the API and creates the attempt
\(attempt = \)this->providers
->forCard($command->providerName)
->initiateCardAttempt(
$command->attemptId,
$payment->getId(),
$command->providerName,
$payment->getAmount(),
$command->context,
);
$payment->markProcessing();
// save and publish — in this order
\(this->attempts->save(\)attempt);
\(this->payments->save(\)payment);
\(this->dispatcher->dispatch(...\)attempt->releaseEvents(), ...$payment->releaseEvents());
return $attempt;
}
}
attemptId comes from the command — it's generated by the calling code, not the use case. This is the same model as CreatePaymentUseCase: the client owns the identifier and is responsible for its uniqueness. This gives idempotency: a repeated request with the same attemptId returns the existing attempt without calling the provider. The same ID is passed to Stripe as Idempotency-Key — if the process crashed between the API call and saving to the DB, a repeated call will return the same PaymentIntent without charging the card again.
Events are published after saving — not before. If save() throws an exception, events won't go out and there will be no divergence between what's in the database and what subscribers saw. Aggregates accumulate events internally via record(), the use case retrieves them via releaseEvents() at the very end.
Full payment lifecycle:
Webhook: idempotent processing
A webhook is the most vulnerable point. Providers deliver events on an "at least once" basis: the same webhook can arrive twice — due to a timeout, retry, or provider behavior. If you simply apply the transition on each receipt, a repeated markSucceeded on an already successful attempt will throw InvalidTransitionException from the state machine.
The solution is simple: don't apply the transition if the attempt is already in a terminal status.
// Skip if attempt is already terminal — this is a repeated delivery
if (\(result->statusChanged && !\)attempt->getStatus()->isTerminal()) {
\(this->applyChannelTransition(\)attempt, $result);
}
// Data is always updated — regardless of status change
if ($result->updatedSpecificData !== null) {
\(attempt->updateSpecificData(\)result->updatedSpecificData);
}
Data updates and status changes are independent operations. This matters for crypto: the provider may send ten webhooks with a growing confirmation count without changing the status — all of them should update the confirmation counter in CryptoAttemptData. Combining these operations into one condition would mean losing data.
Provider integration: Stripe as an example
Theory is good — let's see what this looks like when connecting a real provider.
The provider lives in a separate package
First question: where does the Stripe code physically live? You could put it in the main repository next to the domain. But then the dependency on stripe/stripe-php ends up in the core's composer.json — and anyone using payroad-core drags along the Stripe SDK, even if they'll never use it.
The provider is a separate package with a unidirectional dependency:
payroad/stripe-provider
composer.json:
require:
payroad/payroad-core ← knows about domain objects
stripe/stripe-php ← knows about Stripe API
payroad-core knows nothing about stripe-provider. This allows connecting Stripe only where it's needed, and replacing it with Adyen without touching the core.
What the provider does
The responsibilities of StripeCardProvider are strictly limited: call the Stripe API and return a domain object. Nothing more.
final class StripeCardProvider implements
OneStepCardProviderInterface,
CapturableCardProviderInterface,
TokenizingCardProviderInterface
{
public function initiateCardAttempt(
PaymentAttemptId $id,
PaymentId $paymentId,
string $providerName,
Money $amount,
CardAttemptContext $context,
): CardPaymentAttempt {
// 1. Create PaymentIntent in Stripe
\(intent = \)this->stripe()->paymentIntents->create([
'amount' => $amount->getMinorAmount(),
'currency' => strtolower($amount->getCurrency()->code),
'metadata' => [
'payroad_attempt_id' => (string) $id,
'payroad_payment_id' => (string) $paymentId,
],
]);
// 2. Pack Stripe-specific data into a CardAttemptData implementation
\(data = new StripeCardAttemptData(clientSecret: \)intent->client_secret);
// 3. Create domain aggregate and return
\(attempt = CardPaymentAttempt::create(\)id, \(paymentId, \)providerName, \(amount, \)data);
\(attempt->setProviderReference(\)intent->id);
return $attempt;
}
}
metadata with payroad_attempt_id is the key point. When a webhook arrives, we need to find the attempt by intent.id. But what if this is the first webhook and the attempt hasn't been saved yet? Stripe returns intent.id synchronously, I immediately put it in providerReference — and the repository can search by it. metadata is a fallback in case intent.id somehow changes.
Provider data doesn't mix with the domain
Stripe returns client_secret — a token the frontend needs for confirmCardPayment(). This is a Stripe-specific detail. The domain must not know about client_secret, but the data needs to be passed to the frontend somehow.
The solution: StripeCardAttemptData implements the domain interface CardAttemptData, adding Stripe-specific fields to it:
// In payroad-core — domain contract
interface CardAttemptData extends AttemptData
{
public function getClientToken(): ?string; // neutral name
public function getBin(): ?string;
public function getLast4(): ?string;
// ...
}
// In stripe-provider — concrete implementation
final class StripeCardAttemptData implements CardAttemptData
{
public function __construct(
private readonly string $clientSecret, // Stripe-specific field
private readonly ?string $last4 = null,
// ...
) {}
public function getClientToken(): ?string { return $this->clientSecret; }
}
The domain sees CardAttemptData with neutral methods. Adyen will also return some client token — just with a different internal structure. Changing the provider doesn't change the interface.
Status translation: the provider speaks its own language
Stripe doesn't know about the domain AttemptStatus. Stripe sends payment_intent.succeeded, payment_intent.amount_capturable_updated, payment_intent.payment_failed. A translation layer is needed:
final class StripeStatusMapper
{
public function mapPaymentIntentEvent(string $eventType): ?AttemptStatus
{
return match ($eventType) {
'payment_intent.succeeded' => AttemptStatus::SUCCEEDED,
'payment_intent.payment_failed' => AttemptStatus::FAILED,
'payment_intent.processing' => AttemptStatus::PROCESSING,
'payment_intent.canceled' => AttemptStatus::CANCELED,
'payment_intent.amount_capturable_updated' => AttemptStatus::AUTHORIZED,
default => null, // ignore insignificant events
};
}
}
null for insignificant events is an intentional decision. Stripe sends payment_intent.created, payment_intent.requires_action and other events that carry no meaning for the state machine. The mapper returns null, the webhook handler skips — no error, no unnecessary noise in logs.
amount_capturable_updated → AUTHORIZED is an illustrative example. Stripe describes a technical event ("amount available for capture"), I translate it into a domain term ("authorized"). Domain code doesn't know what "capturable updated" means — it only knows AUTHORIZED.
Provider capabilities are declared through interfaces
StripeCardProvider implements three interfaces at once. This is an explicit declaration: Stripe supports one-step flow, supports capture/void, supports saved cards. A provider that doesn't support capture simply doesn't implement CapturableCardProviderInterface — and the use case won't offer that option.
This works both ways. When writing CaptureCardAttemptUseCase, exactly CapturableCardProviderInterface is needed:
\(provider = \)this->providers->forCard($command->providerName);
if (!$provider instanceof CapturableCardProviderInterface) {
throw new \DomainException("Provider {$command->providerName} does not support capture.");
}
\(result = \)provider->captureAttempt(\(attempt->getRequiredProviderReference(), \)command->amount);
The error is thrown explicitly with a clear message — not Method not implemented from the depths of some abstract class.
Should channels be extracted into separate packages?
When channels are cleanly separated within one repository, a logical next question arises: should they be split into separate packages?
payroad/core — Payment, PaymentAttempt, Money, base ports
payroad/card-channel — CardPaymentAttempt, CardStateMachine, CardAttemptData
payroad/crypto-channel
payroad/p2p-channel
payroad/cash-channel
On paper it looks attractive: you connect only the channels you need, a service that only accepts cards doesn't pull in crypto logic, each channel has its own release cycle.
But before making this decision, it's worth honestly answering a few questions.
Are the channels truly independent? They share PaymentAttempt, AttemptStatus, Money, repository interfaces. Adding a new status to AttemptStatus means releasing a new version of the core and updating the dependency in all channel packages. What is currently one commit becomes five PRs. This is manageable, but it's a real cost — and it grows with each new channel.
What happens to consistency? In a monorepo all channels are always in one commit — it's impossible to have a state where card-channel is updated but crypto-channel isn't. In separate packages this becomes possible. Integration tests that verify all channels work together become a separate infrastructure task.
Who needs this? Splitting into packages makes sense when channels truly evolve independently and by different teams — the card team makes releases weekly, the crypto channel changes quarterly. Then keeping them together means artificially synchronizing independent processes.
I currently keep channels in the core — and this decision deserves to be challenged too. The current structure is convenient while there are four channels and they evolve together. It's not obvious that it will remain correct if there are ten channels or if different teams start working on them. Perhaps the right answer is neither packages nor monorepo, but a monorepo with independent modules inside, where each channel is a separate Composer package in one repository. This gives versioning independence without the operational complexity of multiple repositories.
This is an open question. The clean boundaries between channels that I've drawn are a necessary condition for any of these options. Without them, splitting is impossible in the first place.
Conclusion
Every step in this evolution starts with specific pain and a question: why is this uncomfortable? Nowhere to put a second attempt — the model isn't honestly describing the domain. Attempts from different channels don't fit in one class without nullable fields — I've mixed different concepts. One event requires switch inside the handler — it's not one event.
DDD in this sense is not a set of patterns to apply upfront. It's a way of asking uncomfortable questions when something starts to hurt: what's really happening here? Does this model honestly describe the domain? Why does this code know about things it doesn't need to know?
This experiment grew into Payroad — an open set of PHP libraries for payment processing. Payroad doesn't try to be another Omnipay. It provides a domain model — Payment, PaymentAttempt, channels, state machines, events — that you can connect any provider to and embed into any framework. It's an attempt to answer the question Omnipay and Payum never asked: what should a PHP payment domain look like if you design it honestly.
payroad-core is just the beginning. Work is ongoing on providers (Stripe is already there, others are next), bridge packages for popular frameworks, and eventually — a full-featured payment processing application built on this model.
A payment system is a good testing ground for such honesty. Money doesn't forgive blurry abstractions.
If you've built payment systems differently — I'd genuinely like to hear what worked and what didn't.
And if you spot something wrong or questionable in the model, I'm even more interested!
📩 Write me on [LinkedIn](https://www.linkedin.com/in/nick-zykov/)

