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
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 — 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;
}
}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 — 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 busEvents 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 — 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);
}
}Store events, not state. Aggregates, projections, replay, snapshotting. Partitioned + streaming projections so rebuilds parallelize across workers and catch up in real time — no single-process bottleneck.
One #[Sensitive] attribute encrypts a field in the event store, on the wire over RabbitMQ / SQS / Redis / Kafka / DBAL outbox, and in your structured logs — because all serialization flows through one shared conversion pipeline.
Transactional outbox, DBAL dead-letter queue with replay, retries with exponential backoff, deduplication, OpenTelemetry on every handler — default behaviour, not assembly required.
Projections are trigger-based: on every run they read from the Event Store at their last committed position. Crash at event #42? Fix the bug, deploy, and the projection catches up automatically — no manual reset, no backfill script.
Rebuild a projection on a new version in parallel — concurrent async backfill partitioned by aggregate ID scales rebuilds to millions of events across N workers. The live projection keeps serving queries until the atomic flip.
Haven’t found what you’re looking for? Contact us
Unleash the power of Messaging in PHP
and push productivity to the higher level
