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
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 — 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 — 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);
}
}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 — 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 — 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.
}
}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 — 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,
) {}
}Transactional outbox, DBAL dead-letter queue with replay, retries with exponential backoff, deduplication, OpenTelemetry on every handler — default behaviour, not assembly required.
A copy of the message is dispatched to every handler. Each retries independently, fails independently — no shared envelope, no sibling re-runs. One failing subscriber doesn't abort the others.
RabbitMQ, Kafka, SQS, Redis, DBAL outbox. Move handlers to async with one attribute. CombinedMessageChannel writes the message in the same DBAL transaction as the business state, then dispatches execution to the broker. Atomic. Transports swap without code changes.
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.
Correlation IDs and parent-message IDs propagate from command to every emitted event without middleware. Your OpenTelemetry spans stitch themselves end-to-end — no bundle to install, no stamps to remember.
Haven’t found what you’re looking for? Contact us
Unleash the power of Messaging in PHP
and push productivity to the higher level
