Index

A Process Manager Remembers What the Queue Cannot

An order is paid at 10:00. Inventory is reserved at 10:01. The warehouse does not respond until 14:30. Shipping fails the next morning, after two worker deployments and a payment-provider timeout.

No request, transaction, or queue worker owns that whole timeline. Chaining jobs can move execution from one step to the next, but the queue does not know whether the business process is waiting, complete, compensating, or stuck.

A Process Manager exists to remember that state and decide what should happen when a relevant message arrives. Its value begins where call-stack control ends.

Process Manager and Saga name different decisions

The Enterprise Integration Patterns definition describes a Process Manager as a central component that keeps sequence state and chooses the next processing step from intermediate results. It answers where coordination and routing decisions live.

Garcia-Molina and Salem’s original Saga paper describes a long-lived transaction split into transactions that can interleave with other work. If the sequence cannot complete, compensating transactions amend the partial execution. It answers how committed steps form a larger business transaction without holding one database transaction open.

The ideas overlap, but they are not synonyms. A Process Manager can coordinate a workflow that needs no compensation. The Saga definition does not require one central manager. The fulfilment example below uses a Process Manager to orchestrate a sequence with compensating actions. That sentence is more useful than calling every chain of messages a Saga.

The workflow has state of its own

Consider order fulfilment:

PaymentCaptured
    → reserve inventory

InventoryReserved
    → request shipment

ShipmentBooked
    → complete fulfilment

InventoryRejected
    → refund payment

This looks like event choreography until two events arrive twice, one arrives late, or the process times out. Then the system needs answers that do not belong entirely to payment, inventory, or shipping:

  • Which order does this message advance?
  • Has this message already been handled?
  • Which step is expected now?
  • What work was already requested?
  • Is a late success still acceptable after compensation began?
  • Who can see and repair a process that stopped progressing?

Those are Process Manager responsibilities.

final class FulfilmentProcess
{
    public function __construct(
        public readonly OrderId $orderId,
        private FulfilmentState $state,
        private ?PaymentId $paymentId = null,
        private ?ReservationId $reservationId = null,
        private ?ShipmentId $shipmentId = null,
    ) {}

    /** @return list<object> */
    public function paymentCaptured(PaymentCaptured $event): array
    {
        if ($this->state !== FulfilmentState::AwaitingPayment) {
            return [];
        }

        $this->paymentId = $event->paymentId;
        $this->state = FulfilmentState::ReservingInventory;

        return [new ReserveInventory($this->orderId, $event->eventId)];
    }
}

The returned command is a decision, not proof that delivery succeeded. The process state and outgoing message need a reliable persistence boundary.

Persist the decision and its message together

This transaction is unsafe:

$process->paymentCaptured($event);
$processes->save($process);
ReserveInventory::dispatch($orderId);

The application can crash after saving and before dispatching. Reversing the order creates the opposite gap: a worker may act on a command whose process state later rolls back.

An outbox stores the process update and outgoing message in one database transaction:

DB::transaction(function () use ($event): void {
    $process = $this->processes->getForUpdate($event->orderId);

    foreach ($process->paymentCaptured($event) as $command) {
        $this->outbox->add(OutboxMessage::from($command));
    }

    $this->processedMessages->record($event->eventId);
    $this->processes->save($process);
});

A separate publisher delivers committed outbox rows and records successful delivery. It may publish twice if acknowledgement is lost, so consumers still need idempotency.

Laravel can delay queued jobs and listeners until database commit through its queue transaction configuration and after-commit event interfaces. That prevents a worker from seeing uncommitted state. It does not make saving state and handing a message to an external broker atomic; the outbox addresses that larger gap.

Idempotency belongs to both transport and business state

Recording every processed message ID handles exact redelivery:

if ($this->processedMessages->contains($event->eventId)) {
    return;
}

It does not handle two different events that report the same business fact. The Process Manager must also reject transitions that are no longer valid.

public function inventoryReserved(InventoryReserved $event): array
{
    if ($this->state !== FulfilmentState::ReservingInventory) {
        return [];
    }

    $this->reservationId = $event->reservationId;
    $this->state = FulfilmentState::BookingShipment;

    return [new BookShipment(
        orderId: $this->orderId,
        reservationId: $event->reservationId,
    )];
}

The state check protects the business transition. The inbox record protects the delivery mechanism. They solve different duplication problems.

Concurrent messages add another race. getForUpdate() can use Laravel’s pessimistic locking to serialize handlers when every transition uses the same database. Optimistic version checks are another option. Without either, two workers can read the same state and emit conflicting commands.

Timeouts are messages, not sleeping workers

A long-running process must not hold a PHP worker until tomorrow. Schedule a timeout message with the process identity and expected state:

new FulfilmentTimedOut(
    orderId: $this->orderId,
    expectedState: FulfilmentState::BookingShipment,
    deadline: $clock->now()->addHours(2),
);

When it arrives, the Process Manager checks current state:

public function timedOut(FulfilmentTimedOut $timeout): array
{
    if ($this->state !== $timeout->expectedState) {
        return [];
    }

    $this->state = FulfilmentState::Refunding;

    return [new RefundPayment($this->orderId, $this->paymentId)];
}

A shipment booked just before the timeout was delivered makes the timeout a harmless stale message. The expected-state check is what makes that race safe.

Use a deadline derived from business policy, not a queue retry count. “Three attempts” describes transport behavior. “No shipment within two hours” describes when the business changes course.

The deadline also needs durable delivery. Laravel supports delayed jobs, but the backing transport sets the real limit; for example, its SQS documentation notes a maximum delay of 15 minutes. A multi-hour deadline can instead live in the process table or an outbox available_at column. A scheduler claims due rows and emits the same timeout message. Worker sleep, queue delay, and database polling are mechanisms; the persisted deadline and expected state are the business contract.

Compensation is a new action, not rollback

Once an external payment has been captured, a database rollback cannot undo it. Compensation asks another system to perform a counter-action such as a refund or inventory release.

That action can fail too. Model it as another visible state:

BookingShipment
    → shipment timeout
Refunding
    → RefundCompleted → Compensated
    → RefundFailed    → ManualReview

Do not mark the process failed and hope an operator infers which side effects already occurred. Persist identifiers and state needed to continue or repair the process.

Compensation is not always correct. If a carrier books a shipment after the refund starts, policy may require intercepting the parcel, charging again, or manual review. The process model must state which late events it accepts; a generic Saga abstraction cannot decide that policy.

Test the state table, not a queue script

The smallest useful test gives the Process Manager a current state and one message, then asserts the next state and emitted commands:

it('ignores a stale shipment timeout', function () {
    $process = aFulfilmentProcess(state: FulfilmentState::Completed);
    $timeout = aTimeout(expectedState: FulfilmentState::BookingShipment);

    $commands = $process->timedOut($timeout);

    expect($commands)->toBeEmpty()
        ->and($process->state())->toBe(FulfilmentState::Completed);
});

That test needs no queue mock because it checks a business transition. A separate integration test should run the handler transaction against the real database and prove four infrastructure properties: the inbox rejects the same message ID, the process and outbox commit together, a rollback leaves neither change behind, and concurrent handlers cannot both advance one state.

Publisher tests cover the remaining failure window. Simulate successful publish followed by lost acknowledgement, run the publisher again, and prove that the consumer treats the duplicate as a no-op. The complete suite should include the happy path, exact redelivery, two event IDs for the same fact, a stale timeout, failed compensation, and an operator repair. Mocking a neat sequence of handler calls proves none of those durable properties.

Observability should answer business questions

Queue dashboards report attempts, runtime, and failures. Operators also need process-level signals:

fulfilment_processes{state="booking_shipment"} 42
fulfilment_oldest_seconds{state="booking_shipment"} 3910
fulfilment_transitions_total{from="refunding",to="manual_review"} 3

Store updated_at, the last message ID, failure reason, and relevant external identifiers.

“Replay” must name the intended operation. Redelivering the same message keeps its message ID and should hit the inbox as a no-op. Replaying historical events to rebuild a projection belongs on a separate rebuild path; feeding them into a live Process Manager can repeat external work. Repairing a live process is a new operator command, not a disguised old event.

A repair procedure should:

  1. load the process state, last message, outbox records, and external IDs;
  2. reconcile payment, reservation, and shipment facts with their owners;
  3. choose a typed action such as retry shipment, accept a late booking, or request refund;
  4. submit it with the expected process state, operator identity, reason, and a new idempotency key; and
  5. pass it through the same lock, state transition, and outbox transaction as automated messages.

If the expected state changed during investigation, reject the repair and start again. An audited command can be retried and explained. A row edited by hand can do neither.

An alert such as “oldest process in BookingShipment exceeds two hours” maps to the business deadline. “Queue has 500 jobs” may be important, but it does not say whether any order is late.

Do not introduce a Process Manager for a local transaction

If all steps happen synchronously in one database transaction, an application service is simpler and provides stronger consistency. A Process Manager earns its state when the workflow crosses time, transaction, ownership, or availability boundaries.

A chain of two best-effort notifications may not need durable business state either. Add the pattern when recovery and progress have business meaning, not whenever several handlers form a sequence.

The Process Manager is the durable memory of an interrupted conversation. It should make the next valid action, duplicate handling, timeout policy, compensation, and operator recovery inspectable. If those answers live only in queue configuration and log archaeology, the process is not being managed.