webhook-ledger / webhook-ledger-bundle
Idempotent webhooks consumption with retries and replays, through transactional outbox, using Doctrine and Symfony messenger.
Package info
github.com/adaniloff/webhook-ledger-bundle
Type:symfony-bundle
pkg:composer/webhook-ledger/webhook-ledger-bundle
Requires
- php: >=8.2
- doctrine/collections: ^2.2 || ^3.1
- doctrine/dbal: ^3.8.2 || ^4.0
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.0
- doctrine/persistence: ^3.3.1 || ^4.0
- psr/log: ^1 || ^2 || ^3
- symfony/config: ^7.0 || ^8.0
- symfony/console: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/event-dispatcher: ^7.0 || ^8.0
- symfony/framework-bundle: ^7.0 || ^8.0
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/messenger: ^7.0 || ^8.0
- symfony/routing: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.5
- symfony/doctrine-messenger: ^7.0 || ^8.0
- symfony/var-exporter: ^7.0 || ^8.0
- zenstruck/foundry: ^2.12
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Idempotent webhook reception with transactional outbox, retries and replay, built on Doctrine and Symfony Messenger.
Table of contents
Requirements
- PHP >= 8.2
- Symfony ^7.0 || ^8.0
- Doctrine ORM ^3.0
Installation
composer require webhook-ledger/webhook-ledger-bundle
You need to register the bundle yourself in config/bundles.php:
WebhookLedger\WebhookLedgerBundle::class => ['all' => true],
It then exposes:
- a
POST /wl/webhook/{source}route (webhook reception); - a
bin/console webhook-ledger:replay {uuid} {version}console command (manual replay).
This bundle provides an application service Receiver::replay(), which you can call
if you want to use the replay feature your own way.
Note that you can easily reuse the command behavior with Symfony's command in controller.
Declaring a webhook provider
Each provider (Stripe, GitHub, ...) must implement Domain\Contract\SourceAdapterInterface and be registered by the container as a service.
The bundle's auto-configuration automatically tags it as a webhook_ledger.source_adapter:
final class StripeSourceAdapter implements SourceAdapterInterface { // your code :-) }
Doctrine mapping
The Infrastructure\Doctrine\Entity\WebhookEntry entity must be added to the project's Doctrine mapping (config/packages/doctrine.yaml), and the Messenger transport used for Application\Worker\Message\ProcessWebhookEvent must be a Doctrine transport (doctrine://...) pointing to the same connection as the EntityManager.
Be aware that this is what guarantees the transactional outbox!
Migration
Important: the Doctrine transport must run with
auto_setup=0(seeMESSENGER_TRANSPORT_DSNand thefailedtransport inconfig/packages/messenger.yaml).Letting Messenger create the
messenger_messagestable on its own (default behavior, on the first dispatched message) means an implicit DDL statement could break the atomicity guarantee made by this package (depending on your DB engine).
The messenger_messages table must be created upfront, through a regular migration, before the endpoint receives its first webhook.
Migration example (used for my live instance demo project)
final class Version20260907204611 extends AbstractMigration { public function getDescription(): string { return 'Generate the transports tables.'; } public function up(Schema $schema): void { $this->addSql(<<<EOF CREATE TABLE messenger_messages ( id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4; EOF); $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE messenger_messages'); } }
Guarantees
- Idempotence: unique constraint
(source, external_event_id), a violation triggers aWebhookEntryDuplicationException. - Transactional outbox: receiving a webhook generates both a
webhook_entryand amessenger_messagesrow in an atomic transaction; they both succeed or fail. Then the comboSymfony Messenger+ Doctrine Messenger transport acts as the relay. - Controlled replay: a webhook is only replayable if it is
DEADand its signature was valid (seeWebhookEntry::isReplayable()), with optimistic-locking (aWebhookOutdatedExceptionis thrown on a stale version).
Decisions
-
Raw DBAL
INSERTfor the ledger write (not the ORM). CatchingUniqueConstraintViolationExceptionthrough Doctrine'sEntityManagercloses it, which forces rebuilding it mid-HTTP-request just to keep going. A simple DBALINSERT, catch the violation, respond202either way. Deduplication is enforced by a unique index on(source, external_event_id). -
No home-grown poller reading the ledger. The default design is often a worker doing
SELECT ... WHERE status='received' ... FOR UPDATE SKIP LOCKED. What's built here:Receiverwrites the ledger row and dispatches theProcessWebhookEventmessage in the same DBAL transaction.Symfony's Doctrine Messenger transport (
messenger_messages) plays the role of the outbox - it already has its ownSKIP LOCKED-style concurrent consumption, retry strategy andfailure_transport, so there was no reason to hand-roll it. -
Replay is restricted to
dead, notfailed. Afailedwebhook already has an automatic retry scheduled by Symfony's Messenger component. Onlydead- retries exhausted - is safe to replay. -
Optimistic locking on replay. The
versioncolumn (Doctrine#[ORM\Version]) guards the replay path: two simultaneous replay clicks on the same event, one succeeds, the other gets anOptimisticLockExceptiontranslated into a clear rejection (WebhookOutdatedException) rather than a second dispatch.
Architecture
The bundle follows a strict hexagonal architecture, enforced by deptrac:
Domain/ # contracts, value objects, enums
Application/ # orchestration (Receiver, Worker)
Infrastructure/ # Doctrine, Messenger
Presentation/ # HTTP controller, console command
The business handler (what happens when the webhook is processed) is up to you to implement.
Special warning: since the Transactional Outbox pattern is an
at-least-oncestrategy, your business handler SHOULD BE idempotent, otherwise you will end up with unexpected behavior.
See this article for more information: