The 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.
Booking a consignment with Carrier C is not one operation. You quote it, you reserve capacity, you submit the booking, you wait for their webhook to tell you whether it was accepted, you generate a label, and if it is going outside the UK you file customs paperwork before any of it can move.
Six steps, three of which call somebody else’s system, one of which cannot complete in the same request it started in. And the way this gets built, almost every time, is a column.
final class Consignment{ use IsDatabaseModel;
public ConsignmentStatus $status;}enum ConsignmentStatus: string{ case DRAFT = 'draft'; case QUOTED = 'quoted'; case CAPACITY_RESERVED = 'capacity_reserved'; case BOOKING_SUBMITTED = 'booking_submitted'; case BOOKING_ACCEPTED = 'booking_accepted'; case BOOKING_REJECTED = 'booking_rejected'; case AWAITING_CUSTOMS = 'awaiting_customs'; case CUSTOMS_CLEARED = 'customs_cleared'; case LABEL_GENERATED = 'label_generated'; case READY_FOR_COLLECTION = 'ready_for_collection'; case FAILED = 'failed';}Eleven values, and somewhere in the codebase a method that decides what to do with each of them.
public function advance(Consignment $consignment): void{ if ($consignment->status === ConsignmentStatus::QUOTED) { $this->reserveCapacity($consignment);
return; }
if ($consignment->status === ConsignmentStatus::CAPACITY_RESERVED) { $this->submitBooking($consignment);
return; }
if ($consignment->status === ConsignmentStatus::BOOKING_ACCEPTED) { if ($consignment->destinationCountry !== 'GB') { $this->fileCustoms($consignment);
return; }
$this->generateLabel($consignment);
return; }
// and so on}This works, and it has worked in production for years in plenty of companies, including some very large ones, so I am not going to suggest anybody rewrite it on a Tuesday afternoon on my say-so.
What it cannot do is answer questions about itself.
Nobody can tell you, from that code, which consignments have been sitting in
BOOKING_SUBMITTED for six hours because Carrier C never sent their webhook. The
status says where a consignment got to, not when it got there or what it was
waiting for. There is no record of what the quote said by the time you reach
customs, so if anything downstream needs it you go and fetch it again. And when
the label generation fails after the booking succeeded, nothing in that enum
describes the situation you are actually in, which is that a carrier believes it
is collecting a parcel you cannot produce paperwork for.
The obvious move
Make it a proper state machine. Define which transitions are legal, refuse the
rest, and stop the class of bug where something jumps from DRAFT to
LABEL_GENERATED because of an ordering mistake in a queue.
public function transitionTo(ConsignmentStatus $next): void{ if (! in_array($next, $this->status->allowedTransitions(), strict: true)) { throw new IllegalTransition($this->status, $next); }
$this->status = $next; $this->save();}I like this a lot more than the if-chain and it fixes real bugs. It is also answering a different question from the one that was being asked.
A state machine tells you which transitions are permitted. It has no opinion about which one should happen next, so the if-chain does not go away, it just gets a guard rail. There is still nowhere to keep what you learned in step two by the time you need it in step five, so that data lives on the consignment itself and the consignment slowly accretes fields that only matter during booking.
And it has nothing to say about failure. A state machine can move you to
FAILED. It cannot tell you that you now owe Carrier C a cancellation, because
you successfully reserved capacity with them ninety seconds ago and the thing
that just failed was the customs filing.
The pattern, and the one it gets confused with
Everyone calls this a Saga. Most of the time what they have built is a Process Manager, and while I would normally let a naming quibble go, this one causes people to get compensation wrong.
Saga comes from a 1987 paper by Hector Garcia-Molina and Kenneth Salem. The problem they were solving was long-lived transactions holding database locks for unacceptable periods. Their answer: break the work into sub-transactions, each committing independently, and give every one of them a compensating transaction that semantically undoes it. If step four fails, you run the compensations for three, two and one in reverse. The idea is about how you recover when you cannot roll back.
Process Manager comes from Hohpe and Woolf’s Enterprise Integration Patterns. A central component receives events, keeps the state of an in-flight sequence, and decides which step to trigger next. They contrast it with a Routing Slip, where the itinerary is computed up front and travels with the message. This idea is about who decides what happens next.
Those are answers to different questions, which is why the versus framing you see everywhere is wrong. You can have a choreographed saga, where each step emits an event the next step listens for and compensations chain backwards, with no central component anywhere. You can have a process manager that sequences six steps and has no compensation logic at all, because every step is retryable. And you can have both, which is what most people actually need and what the microservices literature confusingly labels an “orchestration-based saga”.
The practical cost of the muddle is that teams adopt the word saga, build a process manager, and then never write any compensations, on the understanding that the pattern handed them recovery. It did not hand them anything of the sort, and the compensations are still sitting there waiting to be written by somebody.
When this is worth it
The clearest signal is a step that cannot complete in the request that started it. Carrier C returns a 202 and a webhook arrives minutes later, so something has to exist in the meantime that knows a booking is outstanding, what it was for, and what should happen when the answer comes. A status column can record that you are waiting. It cannot record what you are waiting for, or what to do if the answer never comes.
Failure that means undoing rather than retrying pushes you the same way. Retryable failure needs a queue, not a process manager. But if failing at step five means you have to go and release capacity you reserved at step two, then something has to remember that step two happened and know how to reverse it, and that something is either a process manager or a lot of scattered conditionals pretending not to be one.
Accumulated state is the third signal. When step five needs something step two learned, your options are to thread it forward through every intermediate step, to go back to the carrier and ask again, or to give the process somewhere to keep its own working memory, and that last option is more or less the definition of the thing this article is about.
When it is not
A linear sequence of retryable steps does not need this. If the answer to every failure is to try again and there is no compensation to perform, chain your commands and go home. That is a queue, and it is the right tool.
Two or three steps do not need it either, since the if-chain at the top of this article is perfectly serviceable at that size and everything below costs more than it returns.
The one I would argue about longest is using this for something that is really a workflow product. If the business wants to reorder the sequence without a deploy, with approval gates and something visual to edit, then what you are building is a BPM engine, badly, and there are people who sell those. What a process manager suits is a sequence your engineers own outright.
Building it in Tempest
The process gets to be a thing, rather than a column on something else.
use Tempest\Database\IsDatabaseModel;use Tempest\DateTime\DateTime;
final class ConsignmentBooking{ use IsDatabaseModel;
public string $consignmentId; public BookingStep $step; public DateTime $enteredStepAt;
public ?string $quoteReference = null; public ?string $capacityReservation = null; public ?string $carrierBookingId = null; public bool $requiresCustoms = false;}A few details there are worth copying rather than improvising. Use Tempest’s own
DateTime rather than PHP’s DateTimeInterface, since that is what the clock
hands you and mixing the two means converting at every boundary. Both are
supported, with a caster for each, so this is a consistency argument rather than
a compatibility one. And make BookingStep a backed enum because EnumCaster
works on backed cases. There is no #[Table] attribute because you do not need
one: table names default to the pluralised snake_case of the class name, so this
lands on consignment_bookings by itself.
One constraint to know before you design the schema. IsDatabaseModel supplies
its own PrimaryKey $id, and the docs are explicit that you therefore cannot use
UUID primary keys alongside it. If you want the process keyed by a UUID, declare
#[Uuid] public PrimaryKey $id yourself and skip the trait, which costs you the
active-record helpers.
Before any of this goes near production, one caveat that applies to this article and to the first one in the series. Tempest’s database component is marked experimental and sits outside the backwards compatibility promise, and the maintainers have said publicly they are weighing a different approach to the ORM. The pattern here is framework-independent and will outlive whatever they decide. The specific calls may not.
Those nullable fields are the working memory, and they live on the process rather
than on Consignment, so your domain object stops accumulating columns that
matter for ninety seconds during booking and mean nothing at all afterwards.
enteredStepAt is the field that earns its place fastest. It is what lets you
answer the question the status column could not: which bookings have been stuck
in BOOKING_SUBMITTED longer than Carrier C’s stated response time. That query
is your entire operational visibility into this process, and it costs one column.
Each step is a command handler:
#[CommandHandler]public function handle(SubmitBookingToCarrier $command): void{ $booking = query(ConsignmentBooking::class) ->select() ->whereField('consignmentId', $command->consignmentId) ->first();
if ($booking === null) { throw new BookingProcessWasMissing($command->consignmentId); }
$reference = $this->carriers->for($booking->carrier)->submit($booking);
$booking->update( step: BookingStep::BOOKING_SUBMITTED, carrierBookingId: $reference, enteredStepAt: $this->clock->now(), );}update() writes immediately. It validates the parameters, issues an UPDATE
filtered on the primary key, and then copies the values onto the instance, so
there is no separate save() to remember and no chance of the in-memory object
disagreeing with the row.
The null check is not defensive padding. first() returns mixed, the process
row genuinely can be absent if somebody deleted it or the command outlived it,
and a fatal on null->carrier inside a relay process is a considerably worse way
to find out. Match the field name to the property name too, since the column
mapping follows your property names rather than converting them for you.
And the decision about what runs next lives in exactly one place:
final readonly class ConsignmentBookingProcess{ public function advance(ConsignmentBooking $booking): void { $next = match (true) { $booking->step === BookingStep::QUOTED => new ReserveCapacity($booking->consignmentId),
$booking->step === BookingStep::CAPACITY_RESERVED => new SubmitBookingToCarrier($booking->consignmentId),
$booking->step === BookingStep::BOOKING_ACCEPTED && $booking->requiresCustoms => new FileCustomsDeclaration($booking->consignmentId),
$booking->step === BookingStep::BOOKING_ACCEPTED => new GenerateLabel($booking->consignmentId),
default => null, };
if ($next !== null) { command($next); } }}Which looks suspiciously like the if-chain we started with, and there is no point pretending otherwise. The difference is not that the branching disappeared, because branching is inherent to the problem. The difference is that the branching is now the only thing in this class, it operates on a model that holds the process state rather than on your domain object, and it emits commands instead of calling methods, so every step is independently retryable, testable and observable.
The asynchronous step
Carrier C is the reason this is not just tidier code.
SubmitBookingToCarrier finishes without knowing whether the booking succeeded.
The process sits in BOOKING_SUBMITTED, and the answer arrives later at an
endpoint that has no idea any of this is going on:
#[Idempotent]#[Post('/webhooks/carrier-c')]public function __invoke(CarrierCWebhookRequest $request): Response{ command(new RecordCarrierBookingOutcome( carrierBookingId: $request->carrierBookingId, accepted: $request->accepted, ));
return new Ok();}The webhook does not advance anything itself. It records the outcome as a
command, and the handler for that command moves the process on and calls
advance(). The controller stays ignorant of the sequence, which is what lets
the sequence change without touching it.
The #[Idempotent] is not decoration, for the reasons in article four. Carrier C
redelivers, and a redelivered acceptance that advances the process a second time
generates two labels.
Compensation, which nobody writes
Now the failure that started all this. Label generation fails after the booking was accepted, and Carrier C is expecting a parcel.
public function compensate(ConsignmentBooking $booking): void{ $completed = $booking->completedSteps();
foreach (array_reverse($completed) as $step) { match ($step) { BookingStep::BOOKING_ACCEPTED => command(new CancelCarrierBooking($booking->carrierBookingId)),
BookingStep::CAPACITY_RESERVED => command(new ReleaseCapacity($booking->capacityReservation)),
default => null, }; }}This is the saga part, and writing it is not optional if you took capacity from
somebody. Note that it needs completedSteps(), so your process has to record
what it has done rather than only where it is. A single step field tells you
the current position; compensation needs the history. That is a schema decision
you make at the start or regret later.
Compensations also fail, and there is no compensation for a failed compensation. At some point the honest answer is a human, and the process needs a terminal state that means exactly that, with enough context stored on it that the human can act.
What it costs
The obvious one is that a process worth managing is a process worth persisting,
so you have a new table, migrations, and rows that outlive the requests that
created them. Rows in a non-terminal state are work in progress, and work in
progress needs sweeping. Bookings stuck in BOOKING_SUBMITTED for a day will not
resolve themselves.
Testing gets more involved, though mostly in ways that are good for you. You
cannot assert the whole flow in a single call any more, so you end up testing each
handler on its own and then testing advance() as a pure function from state to
next command, which is a far better test than anything the if-chain permitted,
and also a good deal more test code than you were writing before.
Debugging spreads out. A failure that used to be one stack trace is now a process
in some state, a command that failed somewhere, and a relay that may or may not
have retried it, and reconstructing the story means reading a row rather than a
log. This is the cost that surprises people, and enteredStepAt plus a recorded
history is most of the mitigation.
There is a subtler one worth naming. Once a process exists, everything wants to be part of it. Notifications, analytics, that thing marketing asked for. The sequence grows from six steps to fourteen, and a component whose only job was deciding what happens next becomes the place all your business logic lives. Keeping every step as a command helps, since a step that has started doing its work inside the process class is easy to spot in review.
Where this leaves us
If you have a status column with double-digit values and a method that switches on it, the pattern you are missing is probably a process manager. Give the process its own model, let it hold what it learns, record when it entered each step, and keep the next-step decision in one function that only does that.
Then, separately, decide whether you need compensation, because that is the saga question and it is not answered by any of the above. If you took something from somebody in step two that has to be given back when step five fails, you have to write that yourself, and no amount of correct sequencing will do it for you.
Next in the series: your consumer receives an event with an ID, then immediately calls back for the record. Event-Carried State Transfer, and the notification system that generates more traffic than it saves.
The Second Pattern
You are reading Part 7 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: 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.
Sept 2026 · 12 min read