Credit Card Systems on Ecotone

A declined transaction is an inconvenience. A lost transaction is an audit finding. Every authorize, capture, refund, and chargeback has to be reconstructable, replayable, and indistinguishable to the regulator from the original — long after the worker that wrote it has been replaced twice.

Composer package · Laravel or Symfony · PostgreSQL or MySQL · RabbitMQ, Kafka, SQS, Redis, or DBAL outbox

In production

Challenges and how Ecotone solves them

"How did we end up here? What was this transaction's state on the day of the dispute?"

Six weeks after the charge, a chargeback lands. The customer says they were charged twice; the operations team is sure they weren't. The merchant report from that day shows one capture; today's analytics dashboard shows two. A row-update model can answer "what's true now?" — it cannot answer "what was true at 14:03 on the day in question, and what changed it?". With event sourcing the question dissolves: replay the stream for the transaction id up to the disputed timestamp, and the state is reconstructed exactly as it was.

CardTransaction.php
// CardTransaction.php — an event-sourced aggregate as a plain final class

use Ecotone\Modelling\Attribute\Aggregate;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Modelling\Attribute\EventSourcingHandler;
use Ecotone\EventSourcing\Attribute\EventSourcingAggregate;

#[EventSourcingAggregate]
final class CardTransaction
{
    #[Identifier] public string $transactionId;
    private int $authorizedAmount;
    private int $capturedAmount;
    private TransactionStatus $status;

    #[CommandHandler]
    public static function authorize(AuthorizeTransaction $cmd): array
    {
        return [new TransactionAuthorized($cmd->transactionId, $cmd->amount)];
    }

    #[CommandHandler]
    public function capture(CaptureTransaction $cmd): array
    {
        if ($cmd->amount > $this->authorizedAmount - $this->capturedAmount) {
            throw new CaptureExceedsAuthorization();
        }
        return [new TransactionCaptured($cmd->transactionId, $cmd->amount)];
    }

    #[EventSourcingHandler]
    public function applyAuthorized(TransactionAuthorized $e): void
    {
        $this->transactionId    = $e->transactionId;
        $this->authorizedAmount = $e->amount;
        $this->status           = TransactionStatus::Authorized;
    }

    #[EventSourcingHandler]
    public function applyCaptured(TransactionCaptured $e): void
    {
        $this->capturedAmount += $e->amount;
    }
}
Chargeback inquiry: "what was the state of transaction X at 14:03 on 14 March?"Replay the event stream for that transactionId up to the timestamp. The aggregate is reconstructed deterministically — every authorize, capture, refund, and chargeback in commit order.
A new chargeback business rule is deployedUpdate the #[EventSourcingHandler]. The next time the aggregate loads, events replay through the corrected logic — no historical-data migration, no state-divergence question.
Analytics team needs a historical view of refund rates by weekA projection derives the read model from the events. The events stay the source of truth; new projections can be added without touching the transaction stream.

"Two operators captured the same transaction at the same time"

Optimistic-lock collisions are the price of concurrent writes against a versioned aggregate. The right answer is almost never "fail the second user" — it's "retry the command in the same call stack and let the second pass succeed against the now-updated state". Ecotone's #[InstantRetry] makes this declarative and bounded — no try/catch loop in the handler, no risk of the retry escaping to the broker and re-running siblings.

PaymentCommandBus.php
// PaymentCommandBus.php — instant retry on the exceptions that benefit from retry, fail-fast on the rest

#[InstantRetry(
    retryTimes: 3,
    exceptions: [
        OptimisticLockingException::class,    // version conflict — retry resolves it
        DatabaseConnectionFailure::class,     // transient — retry resolves it
    ],
)]
interface PaymentCommandBus extends CommandBus {} // Ecotone Enterprise — declared once on the bus
Two threads call capture simultaneously on the same transactionIdThe first commits, the second hits OptimisticLockingException, #[InstantRetry] reloads the aggregate, the second capture validates against the now-updated capturedAmount and either succeeds or fails on the business rule — which is the correct outcome.
A transient connection drop hits the second captureRetried in the same call stack, no broker traffic, no sibling handler is re-run.
A non-retryable exception (e.g. CaptureExceedsAuthorization) is thrownFails immediately, no retries — the business rule violation is surfaced to the operator without a stall.

"Two transactions committed in concurrent transactions — our projection skipped one of them"

Events committed in concurrent database transactions can be re-ordered relative to their commit timestamps — a transaction that started earlier may commit later. A projection that reads the visible event with the higher position and advances its cursor past the gap will silently skip the late-committing event forever. In a credit-card audit, "silently" is the operative word — nobody notices until the regulator does.

TransactionLedgerProjection.php
// TransactionLedgerProjection.php — gap-aware position is on by default

use Ecotone\Projecting\Attribute\Projection;
use Ecotone\Modelling\Attribute\EventHandler;

#[Projection('transaction_ledger', fromStreams: ['card_transactions'])]
final class TransactionLedgerProjection
{
    public function __construct(private LedgerWriter $ledger) {}

    #[EventHandler]
    public function onAuthorized(TransactionAuthorized $event): void
    {
        $this->ledger->recordAuthorization($event);
    }

    #[EventHandler]
    public function onCaptured(TransactionCaptured $event): void
    {
        $this->ledger->recordCapture($event);
    }
}
A long-running transaction commits its events after a faster transaction that started laterEcotone records the gap in the position sequence and keeps advancing on the visible events. When the missing event becomes visible, the projection picks it up and applies it then — non-blocking, but the late commit is never silently dropped.
Re-deploying with a schema change to the projectionBlue-green rebuild on a new projection version; the new version backfills in parallel while the old one serves queries; atomic flip when ready.
Live projection fell behind during an outageThe projection is trigger-based; on the next run it reads from its last committed position and catches up automatically — no manual reset, no backfill script.

Frequently asked questions

Haven’t found what you’re looking for? Contact us

No. The event store is a table in your PostgreSQL or MySQL — the same database your other Ecotone modules use. No separate cluster to operate, no foreign storage to back up. Your DBA's existing playbook covers the event store.
Snapshots solve that: Ecotone supports per-aggregate snapshots so a long-lived transaction history doesn't have to replay every event from zero on each load. The snapshot cadence is configurable per aggregate.
#[Sensitive] provides field-level encryption with key management — the cryptographic primitive that PCI requires. PCI compliance is end-to-end (network, access controls, audit logging, key custody), and #[Sensitive] is the application-layer encryption piece of that picture. The key-deletion crypto-shred property is what makes data subject deletion possible without rewriting history.
Streaming projections consume from Kafka or RabbitMQ Streams instead of the database table — the storage choice is per stream, not global. Hot streams can move to a stream broker without changing the aggregate code.

Be part of the change with EcotoneCurve

Unleash the power of Messaging in PHP
and push productivity to the higher level

Get started
Gradient
Shapes 1
Shapes 2
DiscordTwitterSupport and ContactTelegram