Skip to Content
Course content

195: Order Fulfillment Workflow Design

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was looking at a legacy project last week—a custom e-commerce build for a client—and I found something that made my skin crawl. They were handling order fulfillment by just updating a status column in the database. It was literally just a string: 'pending', 'paid', 'shipped'. No logic, no guards, just UPDATE orders SET status = 'shipped' WHERE id = 123.

The problem? The warehouse staff accidentally marked an order as "Shipped" before the payment gateway had even confirmed the funds. The product left the building, the money never arrived, and the developer spent three days trying to figure out why the "Payment Received" email didn't trigger. That's why we're talking about workflow design today.

The danger of the "Set Status" method

If I were writing this quickly, I might just write a method like this in my Order class:

public function setStatus(string $status): void {
    $this->status = $status;
    $this->save();
}

Seems clean, right? But let's actually try to use it. Imagine our intended flow is: Pending → Paid → Processing → Shipped.

$order = Order::find(123);
$order->setStatus('shipped'); // I just skipped 'paid' and 'processing' entirely.

PHP doesn't care. The database doesn't care. But the business definitely cares. I've just bypassed the payment check and the packing slip generation. We can't just let the status be "set"; we need to define "transitions."

Mapping the legal moves

I want to move away from "setting" a value and move toward "transitioning" to a state. I'll start by defining a map of what is actually allowed. If I'm in Pending, I can go to Paid or Cancelled. I absolutely cannot go straight to Shipped.

class OrderWorkflow {
    private array $transitions = [
        'pending'    => ['paid', 'cancelled'],
        'paid'       => ['processing'],
        'processing' => ['shipped'],
        'shipped'    => ['delivered'],
        'cancelled'  => [], // Terminal state
        'delivered'  => [], // Terminal state
    ];

    public function canTransition(string $current, string $next): bool {
        return in_array($next, $this->transitions[$current] ?? []);
    }
}

Now, let's see what happens when I try to force a jump again. I'll wrap the status update in a check using this map.

$workflow = new OrderWorkflow();
$currentStatus = 'pending';
$nextStatus = 'shipped';

if (!$workflow->canTransition($currentStatus, $nextStatus)) {
    throw new Exception("Illegal transition from $currentStatus to $nextStatus");
}

There we go. Now the code screams at me when I try to break the business rules. But here is where it gets tricky: fulfillment isn't just about changing a word in a database; it's about doing things. When an order moves to shipped, we need to email the customer. When it moves to paid, we need to alert the warehouse.

Hooking into the transition

If I put the email logic inside my Order class, that class becomes a bloated mess. Instead, I'll adjust my workflow logic to handle "side effects." I'll change the workflow to return a boolean, and if the transition is successful, I'll trigger a specific action.

I'll try a simple approach first: a switch statement. (I know, I know, it's not the most "elegant" design pattern, but let's see if it works for our needs before we over-engineer a State Pattern).

public function transitionTo(Order $order, string $nextStatus): void {
    if (!$this->canTransition($order->getStatus(), $nextStatus)) {
        throw new Exception("Invalid state transition.");
    }

    $oldStatus = $order->getStatus();
    $order->setStatus($nextStatus);
    $order->save();

    $this->triggerSideEffects($oldStatus, $nextStatus, $order);
}

private function triggerSideEffects(string $from, string $to, Order $order): void {
    if ($to === 'paid') {
        $this->notifier->sendWarehouseAlert($order);
    } elseif ($to === 'shipped') {
        $this->notifier->sendShippingEmail($order);
    }
}

I just realized a flaw here. What if sendShippingEmail fails? The order is already marked as shipped in the database, but the customer has no idea. In a real production system, I'd push these side effects into a queue (like RabbitMQ or Redis). For now, the key takeaway is that the workflow manager owns the transition logic, not the data model.

By separating the "what is allowed" (the map) from the "what happens" (the side effects), we've turned a fragile string update into a reliable business process. You've essentially built a finite state machine.




📋 Practical Task

Build a Fulfillment Guard System

You need to implement a FulfillmentManager class that prevents orders from being moved to an invalid state and triggers a simulated notification.

  • Create a FulfillmentManager class with a transition map.
  • Allowed paths: 'pending' → 'paid', 'paid' → 'shipped', 'shipped' → 'delivered', and 'pending' → 'cancelled'.
  • Implement a changeStatus(Order $order, string $newStatus) method.
  • The method must throw an InvalidArgumentException if the transition is not allowed.
  • If the transition is 'paid' → 'shipped', the method should echo: "Notification: Shipping label generated for Order #ID".
  • Create a simple Order class with getStatus() and setStatus() methods to test your manager.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.