Payment Gateways on Ecotone

Stripe, Adyen, and PayPal will retry the same webhook until you 200 it. Your charge handler has to be idempotent against duplicates, recoverable when the database blips, and never silently lose a webhook that can't be processed yet — because losing one is losing a payment.

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

In production

Challenges and how Ecotone solves them

"Stripe retried the webhook three times — and we charged the customer three times"

Stripe (and every other gateway worth integrating with) retries delivery until your endpoint returns 2xx. Network blips, slow handlers, and your own deploys all trigger retries. A unique-index on the payment id silently no-ops the second insert — so you cannot distinguish a duplicate from a re-delivery on the producer side, and meanwhile every side effect downstream of the row insert (notification email, fraud event, ledger entry) has already fired twice.

PaymentCommandBus.php
// PaymentCommandBus.php — one interface, three production behaviours
//
// • #[Deduplicated] on a custom Command Bus is Ecotone Enterprise.
// • #[InstantRetry] and the error channel work on the standard Command Bus too.

use Ecotone\Modelling\Attribute\Deduplicated;
use Ecotone\Messaging\Attribute\Retry\InstantRetry;
use Ecotone\Modelling\CommandBus;

#[Deduplicated(expression: "headers['paymentId']")]
#[InstantRetry(retryTimes: 2, exceptions: [
    DatabaseConnectionFailure::class,
    OptimisticLockingException::class,
])]
interface PaymentCommandBus extends CommandBus {}
StripeWebhookController.php
// StripeWebhookController.php — the gateway entry point

final class StripeWebhookController
{
    public function __construct(private PaymentCommandBus $payments) {}

    public function __invoke(StripeWebhook $hook): Response
    {
        $this->payments->send(
            new CapturePayment($hook->paymentId, $hook->amount),
            metadata: ['paymentId' => $hook->paymentId],
        );

        return new Response('', 204);
    }
}
Stripe retries the same webhook#[Deduplicated] matches headers['paymentId'] against the tracking table; the second send is dropped before the handler runs. No second charge.
Transient DatabaseConnectionFailure mid-handler#[InstantRetry] retries in the same call stack, no broker round-trip; the third attempt typically succeeds with no visible failure to the gateway.
The handler keeps failing past the retry budgetEcotone pushes the message to the configured error channel (dbal_dead_letter); the webhook persists in the database for inspection and replay. The gateway sees 204, the message is recoverable, the operator sees the failure.

"The database committed, but RabbitMQ was down, so the downstream system never knew"

The classic dual-write problem: you persist the Payment row, then publish PaymentCaptured to the broker. Persistence succeeds, the broker is briefly unreachable, the publish silently fails — the row exists but the downstream ledger never debits. Or the opposite: the broker accepts the message first, the consumer fires immediately, and the row hasn't committed yet — the consumer queries and finds nothing.

AsyncChannelConfiguration.php
// AsyncChannelConfiguration.php — outbox + execution channel as one logical channel

use Ecotone\Dbal\Configuration\CombinedMessageChannel;
use Ecotone\Dbal\DbalBackedMessageChannelBuilder;
use Ecotone\Amqp\AmqpBackedMessageChannelBuilder;
use Ecotone\Messaging\Attribute\ServiceContext;

final class AsyncChannelConfiguration
{
    #[ServiceContext]
    public function paymentsOutbox(): array
    {
        return [
            // Storage half: the outbox table, written in the business transaction.
            DbalBackedMessageChannelBuilder::create('payments_outbox'),

            // Execution half: the broker that workers actually consume from.
            AmqpBackedMessageChannelBuilder::create('payments_broker'),

            // Handlers reference 'payments'. Ecotone writes to payments_outbox
            // in the SAME DBAL transaction as the business state change, then
            // a poller drains the outbox into payments_broker for execution.
            CombinedMessageChannel::create(
                'payments',
                ['payments_outbox', 'payments_broker'],
            ),
        ];
    }
}
PaymentCaptureHandler.php
// PaymentCaptureHandler.php — the channel name binds the handler to the outbox

use Ecotone\Messaging\Attribute\Asynchronous;
use Ecotone\Modelling\Attribute\CommandHandler;

final class PaymentCaptureHandler
{
    #[Asynchronous('payments')]
    #[CommandHandler]
    public function capture(CapturePayment $command): void
    {
        // Business logic commits in the same transaction as the outbox row.
    }
}
Database commits, broker unreachableThe row and the outbox entry committed in the same transaction. The outbox poller drains to the broker when it comes back. No silent loss.
Serialization of the second of two messages failsEcotone serializes all outgoing messages before sending any (Message Collector pattern); the failing serialization rolls the transaction back; the first message is never sent as a ghost.
Worker crashes between handler success and ACKThe broker re-delivers; built-in deduplication on the message id (enabled by default with the Dbal Module) detects the redelivery and skips the handler.

"Card data ends up unencrypted in the logs"

PCI scope is determined by the widest place card data appears, not the narrowest. A #[Sensitive] field that is encrypted at rest in the event store but logged in plaintext by a generic structured-logging interceptor is still PCI-in-scope for the whole pipeline. The encryption boundary has to be everywhere the serializer goes.

CapturePayment.php
// CapturePayment.php — the command, encrypted everywhere it travels

use Ecotone\DataProtection\Attribute\Sensitive;
use Ecotone\DataProtection\Attribute\WithEncryptionKey;

// The encryption key is resolved per-merchant from the message header,
// so deleting one merchant's key crypto-shreds their data only.
#[WithEncryptionKey(expression: "headers['merchantId']")]
final readonly class CapturePayment
{
    public function __construct(
        public string $paymentId,
        public int    $amount,
        #[Sensitive] public string $primaryAccountNumber,
        #[Sensitive] public string $cardholderName,
    ) {}
}
Message dispatched onto RabbitMQ / Kafka / SQS / Redis / Dbal outboxThe #[Sensitive] fields are encrypted by the shared serialization pipeline before the payload ever reaches the broker.
Aggregate persists PaymentCaptured event with the same fields marked #[Sensitive]Ciphertext in the event store, not plaintext.
Structured-logging interceptor logs the message envelopeThe #[Sensitive] fields are encrypted because all logging goes through the same conversion pipeline.
Merchant exercises right-to-erasure (or you crypto-shred for a compromised key)Delete the merchant's encryption key. Every event, every log line, every queued message referencing it becomes unreadable everywhere it lives.

Frequently asked questions

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

No. #[Deduplicated] uses Ecotone's tracking table in your database (the Dbal Module ships it). The first message with a given paymentId runs the handler; subsequent messages with the same value are dropped before the handler executes. The expiration policy is configurable (default 7 days, cleaned in batches by an Ecotone console command).
Yes. #[InstantRetry(retryTimes: 2, exceptions: [DatabaseConnectionFailure::class, OptimisticLockingException::class])]. The retries happen in the same call stack — no broker round-trip — which is what makes them "instant" and safe to use under load.
Single-handler #[Deduplicated] (open-source) is per-endpoint; you put it on every handler that needs it. Custom Command Bus #[Deduplicated] (Ecotone Enterprise) declares it once on the interface and every command sent through that bus inherits the behaviour — so you can't forget it on a new handler.
TLS encrypts the wire between two endpoints, but the payload sits in cleartext in your broker, in your event store, and in your structured logs once received. #[Sensitive] encrypts the value end-to-end so the cleartext lives only inside the handler that explicitly decrypts it.

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