Automation Workflows

Building a Multi-Step Approval Workflow Without Overcomplicating It

A real approval workflow — expense requests routed through one or two approvers depending on amount — built with a state machine instead of a tangle of if-statements.

By Aissam Ait Ahmed Automation Workflows 0 comments

Approval workflows have a specific way of getting messy: what starts as "one approver approves or rejects" grows, feature request by feature request, into a tangle of nested conditionals handling escalation, delegation, timeouts, and edge cases nobody planned for up front. The fix that kept ours manageable was modeling it explicitly as a state machine from the start, rather than trying to bolt structure onto a growing pile of if-statements after the fact.

The actual requirements, before writing any code

Our expense approval process needed: requests under a threshold auto-approved by a single manager, requests over the threshold requiring both a manager and a finance approval, a rejection at any stage ending the process immediately, and an escalation if an approver hadn't acted within 48 hours. Writing these out as plain sentences before touching code was the single most useful step — it surfaced the two-approver case as fundamentally different in shape from the single-approver case, rather than a minor variation on it.

Why a tangle of if-statements breaks down here

A first instinct is often a single function checking conditions and deciding what happens next:

function processApproval(Request $expenseRequest, string $action): void
{
    if ($expenseRequest->amount < 500) {
        if ($action === 'approve') {
            $expenseRequest->status = 'approved';
        } elseif ($action === 'reject') {
            $expenseRequest->status = 'rejected';
        }
    } else {
        if ($expenseRequest->manager_approved && $action === 'approve' && $expenseRequest->stage === 'finance') {
            $expenseRequest->status = 'approved';
        } elseif (! $expenseRequest->manager_approved && $action === 'approve') {
            $expenseRequest->manager_approved = true;
            $expenseRequest->stage = 'finance';
            // ... and it keeps growing from here
        }
    }
}

This works for the first two cases and becomes genuinely hard to reason about by the fourth or fifth — every new requirement (escalation, delegation, a second rejection path) adds another branch inside an already-nested structure, and reading this function six months later to answer "what happens if finance rejects after the manager already approved" requires mentally tracing every branch rather than reading a definition of the state directly.

Modeling it as a state machine instead

A state machine makes the set of valid states and the valid transitions between them explicit, rather than implicit in a web of conditionals:

enum ApprovalState: string
{
    case PendingManager = 'pending_manager';
    case PendingFinance = 'pending_finance';
    case Approved = 'approved';
    case Rejected = 'rejected';
    case Escalated = 'escalated';
}

class ApprovalStateMachine
{
    private const TRANSITIONS = [
        'pending_manager' => ['approved', 'pending_finance', 'rejected', 'escalated'],
        'pending_finance' => ['approved', 'rejected', 'escalated'],
        'escalated' => ['approved', 'rejected'],
    ];

    public function canTransition(ApprovalState $from, ApprovalState $to): bool
    {
        return in_array($to->value, self::TRANSITIONS[$from->value] ?? [], true);
    }
}

The TRANSITIONS array is the entire routing logic in one readable place — "what can happen from pending_finance" is answered by reading one array entry, not by tracing nested conditionals across an entire function. This is the same value a state machine brings to any multi-stage process; the specific states change, but the pattern of making valid transitions explicit rather than implicit doesn't.

Deciding which state to enter, separated from whether the transition is valid

class ExpenseApprovalService
{
    public function __construct(private ApprovalStateMachine $machine) {}

    public function submit(ExpenseRequest $expenseRequest): void
    {
        $needsFinance = $expenseRequest->amount >= 500;

        $expenseRequest->update([
            'state' => ApprovalState::PendingManager,
            'needs_finance_approval' => $needsFinance,
        ]);
    }

    public function approve(ExpenseRequest $expenseRequest, User $approver): void
    {
        $currentState = ApprovalState::from($expenseRequest->state);

        $nextState = match (true) {
            $currentState === ApprovalState::PendingManager && $expenseRequest->needs_finance_approval
                => ApprovalState::PendingFinance,
            default => ApprovalState::Approved,
        };

        if (! $this->machine->canTransition($currentState, $nextState)) {
            throw new InvalidApprovalTransition($currentState, $nextState);
        }

        $expenseRequest->update(['state' => $nextState]);
    }
}

Splitting "what state should this move to" from "is that move actually allowed" caught a real bug during testing: an early version of the amount-based branching logic could compute a target state that the transition table didn't actually permit, and the explicit canTransition check surfaced that mismatch as a thrown exception during development, rather than silently corrupting a request's state in production the way the original if-statement version would have, with no error at all — it would have just quietly set an invalid status and moved on.

Adding the escalation timeout — and the bug it had

A scheduled job checks for requests stuck in a pending state past 48 hours and moves them to Escalated, notifying a backup approver:

class EscalateStaleApprovals implements ShouldQueue
{
    public function handle(ApprovalStateMachine $machine): void
    {
        $stale = ExpenseRequest::whereIn('state', ['pending_manager', 'pending_finance'])
            ->where('updated_at', '<', now()->subHours(48))
            ->get();

        foreach ($stale as $request) {
            $request->update(['state' => ApprovalState::Escalated->value]);
            Notification::send($request->backupApprover, new ExpenseEscalated($request));
        }
    }
}

The bug: this job ran hourly and checked updated_at, which also gets touched by unrelated fields — a requester editing the expense description after submission updated updated_at and reset the 48-hour clock, even though no actual approval action had occurred. A request could sit unapproved for well past 48 real hours as long as someone made a trivial edit to it periodically, defeating the entire point of the escalation timer. The fix was tracking a dedicated state_entered_at timestamp, updated only on an actual state transition, rather than relying on the general-purpose updated_at column that Eloquent touches on any change to the model at all.

public function approve(ExpenseRequest $expenseRequest, User $approver): void
{
    // ...
    $expenseRequest->update([
        'state' => $nextState,
        'state_entered_at' => now(), // only touched on actual transitions
    ]);
}

What made this maintainable six months in

  • The transition table is the single source of truth for what's allowed, readable in one place rather than scattered across conditionals — adding a new state or transition means editing one array, not hunting through a function for every place a new branch needs to go.
  • Separating "which state comes next" from "is this transition valid" meant a bug in the first (a wrong target state) got caught by the second, rather than the two concerns being tangled together in a way that let a bug in one hide inside the other.
  • Timestamps used for time-based logic need to track only the specific event they represent, not a general-purpose "last touched" field — the escalation bug above is exactly what happens when those two get conflated.

If your approval process needs the requester to be notified along each step of the chain, the notification-delivery side of that is worth pairing with the reliability patterns in webhook retry logic that doesn't duplicate data — an escalation notification that fires twice because a retry wasn't handled idempotently is its own separate, avoidable bug on top of the state logic covered here.

Adding delegation without breaking the transition table

A few weeks after the initial launch, a real requirement showed up that the original design hadn't accounted for: an approver going on leave needed to delegate their pending approvals to someone else, temporarily, without permanently reassigning every future request. The tempting shortcut was adding a delegate check directly inside the approval methods — "if this approver has an active delegate, let the delegate approve instead" — scattered across every place an approval action happened.

Instead, delegation was handled as a layer that resolves the effective approver before any state-machine logic runs at all, keeping the transition table itself completely unaware that delegation exists:

class ApproverResolver
{
    public function resolveEffectiveApprover(User $originalApprover): User
    {
        $delegation = Delegation::where('delegator_id', $originalApprover->id)
            ->where('starts_at', '<=', now())
            ->where('ends_at', '>=', now())
            ->first();

        return $delegation?->delegate ?? $originalApprover;
    }
}

// In the controller, before calling into the state machine at all:
$effectiveApprover = $resolver->resolveEffectiveApprover($request->user());
$approvalService->approve($expenseRequest, $effectiveApprover);

Keeping delegation resolution entirely outside the state machine meant the transition table stayed exactly as simple as it was on day one — delegation is a question of who is allowed to act, resolved before the state machine ever runs, not a question of what states exist, which is what the state machine is actually responsible for. Mixing the two concerns into one function is exactly the kind of thing that would have put us back on the path toward the original tangled if-statement design this whole approach was meant to avoid.

Auditing who did what, and when

Once more than one person could act on a given request — an approver and, at times, their delegate — a plain "current state" column on the request stopped being enough to answer a question that came up almost immediately: who actually approved this, and when did each transition happen? A separate append-only log table, written to on every transition, gave us that history without complicating the state machine itself:

class ApprovalStateMachine
{
    public function transition(ExpenseRequest $request, ApprovalState $to, User $actor): void
    {
        // ... validity check as before ...

        $request->update(['state' => $to->value, 'state_entered_at' => now()]);

        ApprovalLog::create([
            'expense_request_id' => $request->id,
            'from_state' => $request->getOriginal('state'),
            'to_state' => $to->value,
            'actor_id' => $actor->id,
            'occurred_at' => now(),
        ]);
    }
}

This log turned out to matter for a reason that had nothing to do with debugging: a finance audit several months in specifically asked for a record of every approval decision and who made it, and having an append-only log already in place meant that request was a straightforward query rather than a scramble to reconstruct history from timestamps and inference.

Deciding what not to build

It's worth naming what we deliberately left out, because the temptation to keep adding flexibility to a system like this doesn't stop on its own. We didn't build configurable, per-department approval chains, even though it was requested — every department currently follows the same manager-then-finance shape, and building a generic chain-configuration system for a requirement that didn't yet exist would have meant carrying real complexity for a flexibility nobody was using. If a genuinely different approval shape shows up for a specific team, the state machine's transition table is straightforward to extend with a new state — but extending it when a real need appears is a smaller, safer change than speculatively building for a shape of flexibility that might never get used.

  • Build the state machine for the approval shapes you actually have today, not every shape you can imagine needing eventually.
  • Keep cross-cutting concerns like delegation resolved outside the state machine, not folded into its transition logic.
  • An append-only log of every transition costs very little to add up front and answers questions — audits, disputes, "wait, who approved this" — that a single mutable status column simply can't answer after the fact.

Testing the state machine itself, independent of the surrounding app

Because the transition table is a plain array with no dependency on the database, controllers, or queued jobs, it's testable in complete isolation — and doing so caught more edge cases faster than testing through the full HTTP request cycle would have:

it('does not allow approving a request that was already rejected', function () {
    $machine = new ApprovalStateMachine();

    expect($machine->canTransition(ApprovalState::Rejected, ApprovalState::Approved))
        ->toBeFalse();
});

it('allows escalation from either pending state', function () {
    $machine = new ApprovalStateMachine();

    expect($machine->canTransition(ApprovalState::PendingManager, ApprovalState::Escalated))->toBeTrue();
    expect($machine->canTransition(ApprovalState::PendingFinance, ApprovalState::Escalated))->toBeTrue();
});

it('does not allow transitioning out of a terminal approved state', function () {
    $machine = new ApprovalStateMachine();

    expect($machine->canTransition(ApprovalState::Approved, ApprovalState::PendingManager))
        ->toBeFalse();
});

Writing these tests directly against the transition table, rather than through the full approval service and its database dependencies, meant the whole set ran in a fraction of a second and could be re-run on every single change to the table without any test-database setup — which made it realistic to run them constantly during development rather than only before a release, and that faster feedback loop is a big part of why the eventual delegation and escalation additions didn't quietly break an existing transition rule along the way.

A rejected-then-resubmitted edge case we almost missed

One case the original transition table didn't handle explicitly: what happens when a rejected request is edited and resubmitted by the requester? The instinct was to treat this as a brand-new request, but the actual product requirement — surfaced by an approver, not by us anticipating it — was that a resubmission needed to retain its original request ID and history for audit purposes, while starting a fresh approval cycle. This meant adding a specific, named transition rather than reusing the initial PendingManager entry point silently:

private const TRANSITIONS = [
    'pending_manager' => ['approved', 'pending_finance', 'rejected', 'escalated'],
    'pending_finance' => ['approved', 'rejected', 'escalated'],
    'escalated' => ['approved', 'rejected'],
    'rejected' => ['pending_manager'], // resubmission, explicit and logged
];

Allowing rejected → pending_manager as its own explicit, logged transition — rather than just resetting the status field back to its initial value — meant the audit log correctly showed a full history of "rejected, then resubmitted, then approved" rather than making the original rejection disappear from the record entirely. Small detail, and it's exactly the kind of thing an explicit transition table makes easy to get right, because adding the rule is a one-line addition to a table rather than a new conditional branch buried somewhere in application logic, which is the entire point of choosing this structure over the tangle of if-statements this whole approach started out trying to avoid.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Automation Workflows Free Resources Explore Tools