The Second Pattern: Event-Carried State Transfer
A consumer that receives an ID and immediately asks you for the record has not been decoupled from you. It has been given a slightly slower way to call your API.
The notification service is a separate deployable. It sends the emails and the SMS messages, it knows about templates and locales and quiet hours, and it has no business knowing anything about carriers. When a consignment goes out for delivery it needs to tell the recipient.
So you publish an event.
final readonly class ConsignmentDispatched{ public function __construct( public string $consignmentId, public DateTime $dispatchedAt, ) {}}That is the textbook shape. The event says what happened, the consumer decides what to do about it, and the two services share nothing more than a small contract.
Then look at what the consumer has to do with it.
public function handle(ConsignmentDispatched $event): void{ $consignment = $this->consignmentsApi->get($event->consignmentId);
$this->notifier->send( to: $consignment->recipientEmail, template: 'out-for-delivery', data: [ 'reference' => $consignment->reference, 'carrier' => $consignment->carrierName, 'expectedDelivery' => $consignment->expectedDelivery, ], );}Every event turns into a request back to you. Ten thousand dispatches on a Monday morning means ten thousand events and ten thousand API calls arriving within a few minutes of each other, all asking for records you had in memory when you published.
The result is a notification system generating more load than the dispatching it exists to report on, which is not a sentence anybody set out to write.
There is a second problem, quieter and worse. That callback returns the current state of the consignment, not the state it was in when it was dispatched. If a carrier updates the expected delivery date in the seconds between your event and their lookup, the email describes a consignment that does not match the event that triggered it. Nobody will notice for months, and when somebody does notice it will be because a customer got told something wrong.
The obvious move
Put a cache in front of the lookup endpoint. The requests are nearly identical and they arrive in a burst, so the hit rate will be excellent and the graph will look much better by lunchtime.
It does work, and I would not undo it if it were already there. But notice what has happened: you have added infrastructure to make a chatty design cheaper rather than making it less chatty. The cache needs invalidating, which is now a second correctness problem sitting on top of the first, and the staleness it introduces is genuinely harder to reason about than the staleness you already had.
The burst also survives it. Ten thousand notifications after a cold deploy still means ten thousand requests, they just resolve faster.
And none of it touches the ordering problem, because a cached read is still a read of whatever is current rather than what was true when the event fired.
The pattern
Martin Fowler set out four different things people mean by event-driven, and two of them are relevant here. What we built above is Event Notification: a small message saying something happened, with the consumer expected to go and find out the details. What we want instead is Event-Carried State Transfer, where the event carries the state the consumer needs, so the consumer never has to ask.
final readonly class ConsignmentDispatched{ public function __construct( public string $consignmentId, public string $reference, public string $recipientEmail, public string $carrierName, public DateTime $expectedDelivery, public DateTime $dispatchedAt, public int $version, ) {}}The callback is gone, and so is the race, because the event describes the world as it was when it was published rather than pointing at a record that keeps moving.
That version is not optional, and it is the field people leave out. Events are
not guaranteed to arrive in order, so a consumer that applies whatever it
receives will eventually apply an older state on top of a newer one and go
backwards. The consumer keeps the version it has seen and ignores anything lower.
Without that, this pattern is a data corruption mechanism with good intentions.
The deeper shift is what the consumer becomes. It is no longer asking questions about your data, it is maintaining its own copy of the slice it cares about. The notification service ends up with a small table of consignments it might need to write to people about, which it owns, which survives you being down, and which it can query without a network call.
When this is worth it
Start by comparing the callback rate to the event rate. When almost every event triggers a lookup, that lookup is not adding information to anything, it is latency and load you agreed to for no return.
It becomes compelling when the consumer needs to work while you are unavailable. A notification service holding its own data can keep sending during your deployment. One that calls back cannot, and an outage in your service becomes an outage in a service that has no dependency on you except one you designed in.
And it is the right answer when the consumer needs the state as it was, not as it is. Anything that emails, invoices, audits or reports has this requirement, and a callback fundamentally cannot satisfy it.
When it is not
Do not reach for this when the consumer is in the same process. Tempest’s event
bus is synchronous and in-process, and the docs describe it in exactly those
terms, so a handler receiving ConsignmentDispatched can call a repository
directly with no network involved at all. There is no chattiness to remove.
Fattening the event there buys you nothing and costs you a payload to maintain.
Large or constantly changing state rules it out too. A consumer that wants the whole consignment including every movement is not a candidate, since you would be shipping the entity on every change so that it can maintain a replica it mostly never reads.
The one that should give you pause is personal data. Every consumer holding its own copy is another place a deletion request has to reach, another system in your data inventory, another export when somebody asks. Copying a recipient’s email address into three services is a decision with legal consequences and it is worth making deliberately rather than discovering it during an audit.
Building it in Tempest
The mechanism is the interesting part here, because the obvious one is wrong.
The event bus will not do this. It is synchronous and in-process by design, and
GenericEventBus::dispatch() resolves handlers and calls them on the same stack.
Nothing crosses a process boundary, which means it cannot reach a separate
deployable and there is no state transfer to carry.
What crosses the boundary is the async command bus:
use Tempest\CommandBus\Async;
#[Async]final readonly class PublishConsignmentDispatched{ public function __construct( public string $consignmentId, public string $reference, public string $recipientEmail, public string $carrierName, public DateTime $expectedDelivery, public DateTime $dispatchedAt, public int $version, ) {}}Dispatch it inside the transaction that marks the consignment dispatched, exactly as in article three, and the intent to publish commits with the state change or not at all.
The handler is what actually publishes, and it belongs on your side of the boundary:
use Tempest\DateTime\FormatPattern;
#[CommandHandler]public function handle(PublishConsignmentDispatched $command): void{ $this->broker->publish('consignments.dispatched', [ 'consignmentId' => $command->consignmentId, 'reference' => $command->reference, 'recipientEmail' => $command->recipientEmail, 'carrierName' => $command->carrierName, 'expectedDelivery' => $command->expectedDelivery->format(FormatPattern::ISO8601), 'dispatchedAt' => $command->dispatchedAt->format(FormatPattern::ISO8601), 'version' => $command->version, 'schema' => 1, ]);}If you are arriving from Laravel, the formatting will catch you out. Tempest’s
format() takes an ICU pattern rather than PHP’s date() characters, so passing
'c' does not give you an ISO 8601 string, it gives you whatever ICU makes of a
lowercase c. Use the FormatPattern cases and the problem disappears.
Note the explicit array rather than handing the object over. That matters more
here than anywhere else in this series, and the reason is buried in the storage
we looked at in article three: pending commands are stored as serialize($command).
PHP’s serialised format encodes your class name, so renaming or moving that
class turns every pending copy into a __PHP_Incomplete_Class that no handler
will ever be found for, and the row sits there being retried for as long as the
relay is up.
For a small command that is a nuisance. For a command whose entire purpose is
carrying state to another system, it is the failure mode you least want, so
convert to a stable shape you control at the earliest opportunity and put a
schema number on it.
What the consumer keeps
Worth being concrete about what lands on the other side, because “the consumer maintains its own copy” sounds heavier than it is.
final class KnownConsignment{ use IsDatabaseModel;
public string $consignmentId; public string $reference; public string $recipientEmail; public string $carrierName; public DateTime $expectedDelivery; public int $version;}That is six columns, and it is not a replica of your consignment. It is the slice the notification service needs in order to write to somebody, owned by the service that actually uses it. When the product manager asks whether notifications can include the carrier’s name, that is now a question about one table rather than an integration.
The ordering problem you inherit
The version field earns its place immediately, because ordering is not
something the relay gives you. getPendingCommands() selects everything pending
with no ORDER BY, so two updates to the same consignment can be published in
either order.
Which means the consumer has to do the work:
public function apply(array $payload): void{ $known = $this->consignments->find($payload['consignmentId']);
if ($known !== null && $known->version >= $payload['version']) { return; }
$this->consignments->upsert($payload);}Five lines, and without them the pattern quietly corrupts the consumer’s copy whenever two events for the same consignment overlap. This is also why the version has to come from the producer’s own record rather than being generated at publish time. A timestamp added when the message is sent tells you when it was sent, not which state is newer.
Where a new consumer gets its history
Nobody asks this one until the day it matters. You add a fourth consumer, or you rebuild the notification service’s database, and it starts with nothing. Every event describing the consignments it needs was published before it existed, and events are not a place you can go and look things up.
There are three answers and you should pick one on purpose rather than finding out which one you have.
Keep the messages, if your broker supports retention long enough to replay from the beginning. This is the neatest option when it is available and it is the one people assume they have without checking what their retention is actually set to.
Publish a snapshot on request, which means an endpoint or a command that walks your consignments and republishes current state for a given consumer. It is more work and it has the advantage of being something you can run at three in the morning without understanding your broker’s internals.
Or accept that consumers start empty and only know about consignments dispatched after they came online, which is a completely legitimate choice for something like notifications where historical consignments are not going to be emailed about anyway. Just make it a decision that somebody wrote down.
The version check above makes all three safe to run, incidentally, because a replay of state the consumer already has at an equal or lower version is ignored rather than reapplied.
What it costs
You have duplicated data on purpose, and the copies will disagree. Not theoretically: a dropped message, a consumer that was down during a deploy, a bug in the version check, and a consumer is now permanently behind with nothing to tell it so. Every serious use of this pattern eventually grows a reconciliation job that replays or compares, and it is better to plan that than to write it in a hurry during an incident.
The events get fat, and then they get fatter. Each new consumer wants one more field, nobody ever removes one, and in a year the event carries forty properties because it is easier to add than to ask why. The discipline is to add fields for consumers that exist rather than consumers you imagine.
The contract you are left with is far harder to change than the one you replaced.
An ID and a timestamp can be extended almost freely, whereas a payload that three
consumers parse and store cannot, and every field you add is one somebody may
come to depend on without telling you. A schema number will not solve that, but
it does mean a consumer can recognise a payload it was not written for instead of
misreading it.
And there is the personal data point again, because it belongs in both sections. Once that email address is in the notification service’s own table, deleting a customer means deleting it there too, and knowing that requires somebody to have written down where the copies went.
Where this leaves us
A consumer that receives an ID and immediately asks you for the record has not been decoupled from you, it has been given a slightly slower way to call your API. Putting the state in the event removes the call, removes the race, and lets the consumer keep working when you are not.
What you take on in exchange is a copy that can drift, a payload that will grow, and a version number you must not forget. On Tempest specifically, use the async command bus rather than the event bus, since the event bus never leaves the process, and convert to an explicit payload before publishing so a refactor cannot orphan messages already in flight.
Next in the series: five enrichment steps that each need the previous one’s output, and a new step that has nowhere obvious to go. The Blackboard, and what happens when there is no correct order.
The Second Pattern
You are reading Part 8 of 10 in this learning series.
Keep Reading
The Second Pattern: The Ones I Left Out
Five patterns I know well, that come up constantly, and would not reach for in PHP. Not because they are bad ideas, but because of what the runtime does and does not give you.
Sept 2026 · 12 min read
PHPThe Second Pattern: Blackboard
A pipeline works until one step both needs and improves the same piece of information. That is a cycle, and a topological sort has exactly one contract: there are no cycles.
Sept 2026 · 12 min read
PHPThe Second Pattern: Process Manager
Everyone calls it a Saga. Most of the time it is a Process Manager, and the muddle is why teams adopt the word and then never write the compensations it never promised them.
Sept 2026 · 14 min read