- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A customer places an order. The use case charges the card, saves the order, and fires an OrderPlaced event. A listener on that event sends the confirmation email and pushes a row into the warehouse queue.
Now the save fails. A deadlock, a constraint violation, a lost database connection at the wrong moment. The transaction rolls back. No order row exists.
But the email already went out. The warehouse already has a pick ticket for an order nobody can find. Support gets a ticket that reads "where is my order?" and there is no order to point at, because the event fired before the commit that was supposed to make it real.
This is the ordering bug. It ships in a lot of PHP apps because the natural place to dispatch an event feels like the moment the thing happens in code, not the moment the database agrees it happened. Those are different moments, and the gap between them is where the double-fire lives.
Where the event usually gets dispatched
Here is the shape most codebases start with. The aggregate raises an event and hands it straight to a dispatcher, inside the same method that changed state.
<?php
declare(strict_types=1);
final class Order
{
public function __construct(
private readonly EventDispatcher $dispatcher,
) {}
public function place(/* ... */): void
{
// ... build the order, run invariants ...
$this->dispatcher->dispatch(
new OrderPlaced($this->id),
);
}
}
Two problems stack up here.
First, the aggregate now holds a reference to an EventDispatcher. A domain object that should model a business rule is wired to a piece of infrastructure. You cannot build an Order in a unit test without also standing up a dispatcher.
Second, and worse, dispatch runs now. If the surrounding transaction has not committed, the listeners run against a world that may never exist. Send the email, enqueue the job, call the third-party webhook. Then the transaction rolls back and none of it can be taken back.
Some teams try to patch this by moving the dispatch into the repository, right after save(). That is closer, but if save() only stages the write and the real commit happens later at the end of the request, you have only moved the problem three feet.
Collect on the aggregate, dispatch nowhere
The fix starts by having the aggregate record what happened without deciding when the world hears about it. The event is a fact the aggregate carries; it never makes the call itself.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
trait RecordsEvents
{
/** @var list<object> */
private array $pendingEvents = [];
protected function recordEvent(object $event): void
{
$this->pendingEvents[] = $event;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->pendingEvents;
$this->pendingEvents = [];
return $events;
}
}
The aggregate uses it and stays free of any dispatcher:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
final class Order
{
use RecordsEvents;
public static function place(
OrderId $id,
CustomerId $customerId,
\DateTimeImmutable $now,
): self {
$order = new self($id, $customerId, $now);
$order->recordEvent(
new OrderPlaced($id, $customerId, $now),
);
return $order;
}
}
releaseEvents() hands the events out once and clears the buffer, so a second call returns nothing. That drain-once behavior matters: it stops the same event being published twice if the collection step runs more than once.
Now the aggregate is testable in isolation. Call Order::place(...), then assert releaseEvents() contains one OrderPlaced. No dispatcher, no mocks, no infrastructure.
Release after the commit, not before
The events sit on the aggregate until the write is durable. That means someone has to pull them off after the transaction commits and only then hand them to the dispatcher. The place that owns the transaction boundary is the place that owns the release.
A transactional decorator around the use case is a clean home for it. It opens the transaction, runs the work, commits, and only then collects and dispatches.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use App\Application\Port\UseCase;
use App\Domain\EventCollector;
use App\Domain\EventPublisher;
use Doctrine\DBAL\Connection;
final readonly class TransactionalUseCase implements UseCase
{
public function __construct(
private UseCase $inner,
private Connection $connection,
private EventCollector $collector,
private EventPublisher $publisher,
) {}
public function execute(object $input): object
{
$this->connection->beginTransaction();
try {
$output = $this->inner->execute($input);
$this->connection->commit();
} catch (\Throwable $e) {
$this->connection->rollBack();
$this->collector->clear();
throw $e;
}
// Only reached after a successful commit.
$this->publisher->publishAll(
$this->collector->release(),
);
return $output;
}
}
Read the ordering. The dispatch line sits below the commit() and outside the try. If the commit throws, you never reach it. On rollback you clear the collected events so a later request cannot inherit a stale buffer. No listener runs against a row that was never written.
The EventCollector gathers events from every aggregate touched during the unit of work. The repository is a natural spot to feed it: whenever a repository saves an aggregate, it drains that aggregate's events into the collector.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use App\Domain\Order\Order;
use App\Domain\EventCollector;
final readonly class OrderRepository
{
public function __construct(
private Connection $connection,
private EventCollector $collector,
) {}
public function save(Order $order): void
{
// ... write the row(s) ...
$this->collector->collect(
$order->releaseEvents(),
);
}
}
By the time the decorator calls commit(), the collector holds every event raised in this request. After the commit, it releases them in one pass.
The gap between "committed" and "delivered"
Moving the dispatch after commit closes the rollback bug. It opens a smaller one you should name out loud: the process can die between the commit and the publish.
The row is saved. The email never sends, because the PHP worker got OOM-killed in the millisecond after commit() returned. Now the reverse of the first bug: an order exists with no downstream effect.
For in-process listeners that only touch the same database, you can fold them into the same transaction and accept that they commit together or not at all. Anything that leaves the process (email, a message broker, an HTTP webhook) needs a stronger guarantee: the transactional outbox. Write the event to an outbox table inside the same transaction as the order. A separate relay reads that table and publishes, marking each row as sent.
CREATE TABLE outbox (
id BINARY(16) NOT NULL PRIMARY KEY,
type VARCHAR(255) NOT NULL,
payload JSON NOT NULL,
occurred_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL
);
The event row commits atomically with the order. If the commit fails, the outbox row rolls back with it, so there is nothing to publish. If the relay crashes mid-publish, the row still says unpublished and the next relay pass retries it. Delivery becomes at-least-once instead of maybe-once, and downstream handlers carry an idempotency key to absorb the duplicate.
Post-commit dispatch and the outbox are not competing choices. Post-commit dispatch fixes the rollback double-fire. The outbox fixes the crash-after-commit gap. A system that dispatches inside the transaction has neither, and it is the one paging someone at 3am.
What to check in your own code
Open the use case that creates your most important aggregate and answer three questions.
Does the aggregate hold a dispatcher or an event bus in its constructor? If yes, the domain is wired to infrastructure and events are dispatching too early. Move to recorded events.
Where does dispatch or publish run relative to commit? Grep for both in the same file. If the publish line runs before or inside the transaction, the rollback path is firing events on writes that never landed.
What happens to collected events on rollback? If nothing clears the buffer, the next request in a long-running worker (Swoole, RoadRunner, Octane) can pick up events from a request that already failed. Clear on rollback, always.
The pattern is small. The aggregate records facts. The transaction boundary owns the commit. The publish happens strictly after the commit succeeds, and anything crossing the process edge goes through an outbox. Each concern sits in exactly one place, and the ordering stops being a bug you ship.
Keeping the dispatcher out of the aggregate and the publish at the transaction boundary is the same move the whole book is built on: infrastructure lives at the edge, the domain stays a set of facts and rules, and the wiring that decides when the outside world hears about a change is an adapter concern, not a domain one. Decoupled PHP walks the recorded-events pattern, the transactional decorator, and the outbox relay from first principles up to a production shape you can drop into a Symfony or Laravel app.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)