alexandrebulete / ddd-outbox-bundle
Asynchronous effects you can trust in a DDD application: sent if and only if the action commits, tracked one by one, retried from the back office — on the Messenger Doctrine transport, without an extra outbox table.
Package info
github.com/AlexandreBulete/ddd-outbox-bundle
Type:symfony-bundle
pkg:composer/alexandrebulete/ddd-outbox-bundle
Requires
- php: ^8.4
- alexandrebulete/ddd-doctrine-bridge: ^1.4
- alexandrebulete/ddd-foundation: ^1.5
- alexandrebulete/ddd-sylius-bundle: ^1.0
- alexandrebulete/ddd-symfony-bundle: ^1.5
- doctrine/dbal: ^4.3
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/doctrine-migrations-bundle: ^3.7 || ^4.0
- doctrine/migrations: ^3.8
- doctrine/orm: ^3.3
- pagerfanta/core: ^4.0
- psr/clock: ^1.0
- psr/log: ^3.0
- sylius/admin-ui: >=0.10
- sylius/grid-bundle: ^1.13
- sylius/resource-bundle: >=1.14
- symfony/clock: ^7.0 || ^8.0
- symfony/config: ^7.0 || ^8.0
- symfony/console: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/doctrine-bridge: ^7.0 || ^8.0
- symfony/doctrine-messenger: ^7.0 || ^8.0
- symfony/framework-bundle: ^7.0 || ^8.0
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/messenger: ^7.0 || ^8.0
- symfony/routing: ^7.0 || ^8.0
- symfony/security-csrf: ^7.0 || ^8.0
- symfony/translation: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Asynchronous effects you can trust: nothing lost, nothing phantom, nothing twice — and every one of them followed, explained and retried on its own.
There is no outbox table in this bundle. On the Messenger Doctrine transport, a message sent while an action is handled is inserted in that action's transaction: it leaves if and only if the action commits. The transport is the outbox. What this bundle adds is what makes it safe to rely on: a guard against the one way to break that property, a follow-up of every message, a back-office screen and a retry.
If you come from a hand-rolled outbox (a table plus a relay every minute): there is no relay, no minute of latency — PostgreSQL's LISTEN/NOTIFY wakes the worker on insert — and one reaction is one message, retried on its own.
Install
composer require alexandrebulete/ddd-outbox-bundle bin/console doctrine:migrations:migrate
# config/routes/outbox.yaml — the "Retry" action of the back office outbox: resource: '@DddOutboxBundle/config/routes.php'
Requires alexandrebulete/ddd-symfony-bundle ≥ 1.5, whose tracing gives each
message its id, its actor and its chain.
The promise holds on these conditions — check them once:
framework: messenger: failure_transport: failed transports: # Doctrine, on the application's own connection: that is the outbox. async: 'doctrine://default' failed: 'doctrine://default?queue_name=failed'
- Doctrine transport, same connection as the entities. Another transport (Redis, AMQP) cannot join the database transaction: the guarantee is gone.
- A failure transport: that is where failed jobs wait to be retried.
- On PostgreSQL, keep
use_notify(on by default): it is what makes the latency milliseconds rather than a polling interval. A delayed message — an automatic retry — is not notified when it becomes due: the worker looks for them everycheck_delayed_interval(60 000 ms by default). Lower it on the transport if retries must be quicker.
Writing an asynchronous effect
One reaction with an effect, one message — never a domain event fanned out to N asynchronous subscribers: each effect then has its own retries, its own failure, its own retry by hand.
// Routed to `async`. Not a CommandInterface: those are routed to `sync`. final readonly class SendMissionReport { public function __construct(public string $missionId, public string $step) {} } // In the handler of the action — inside its transaction: $this->commandBus->dispatch(new SendMissionReport($missionId, $step));
The handler must be idempotent (at-least-once delivery): derive a key from what the message is about — (mission, step) — and do nothing if it is done.
Never DispatchAfterCurrentBusStamp for an asynchronous message: it would
leave after the commit, and be lost if the process stops in between. The
bundle refuses it with a LogicException.
What is guaranteed
| How | |
|---|---|
| Sent if and only if the action commits | the Doctrine transport inserts in the action's transaction; DispatchAfterCurrentBusStamp refused |
| A failure does not freeze the queue | each message is retried on its own, then parked in the failure transport; a message that closes the EntityManager does not take the next ones down (the worker resets it between messages) |
| Every message followed | a job per message: queued → running → succeeded | retrying → failed |
A job is written in the same transaction as its message: it exists if and
only if the message does. It carries the message, the transport, who is
behind it, its chain (correlation_id, causation_id — the activity journal
of ddd-activity-bundle shares them), its attempts and last error.
Only messages leaving for a real transport are followed; sync is a function
call.
Back office
With Sylius Admin UI, "Background jobs" lists jobs with filters (status, job,
chain): what is stuck, started by whom, since when, why. A failed job has a
Retry button: RetryJobCommand, a use case like any other — a permission
(outbox.retry_job), a line in the journal. The message goes back on its
transport with a fresh set of attempts, still on behalf of whoever started it.
Retention
bin/console outbox:purge # succeeded jobs older than outbox.retention_days
Failed jobs are kept: they still wait for someone.
Configuration
outbox: table: outbox_job # default failure_transport: failed # default retention_days: 30 # default admin: enabled: true # false = no Sylius screen (headless) grid_limits: [25, 50, 100]
Development
composer install
composer qa # phpstan (max + strict rules), deptrac, phpunit
The integration tests run a real worker on real Doctrine transports, on the
database given by DDD_TEST_DATABASE_URL (a SQLite file otherwise); CI runs
them on PostgreSQL, MySQL and SQLite.