Workflow Engines

Modelling Complex Business Processes in Code


Every business has processes. Orders flow through approval chains. Support tickets escalate based on rules. Projects move through stages with checkpoints and handoffs. Most software handles simple workflows fine. It's the complex ones (with exceptions, parallel paths, and conditional logic) that break things.

Workflow engines model these processes in code. When built properly, they handle the messy reality of business operations without becoming unmaintainable tangles of if-statements.

State Machine in Action

See how an order moves through states with controlled transitions.

End-to-End Fulfilment Topology
Visualising cross-departmental friction points
Customer Sales Ops Finance
Customer
Sales
Ops
Finance
01. Trigger

Inquiry Received

02. Field Ops

Site Survey

03. Sales

Prepare Quote

REJECTED
04. Customer

Review Proposal

05. Sales

Revision Logic

06. Decision

Quote Accepted

07. Finance

50% Deposit

DELAY
08. Logistics

Order Materials

09. Execution

Installation

10. Finance

Balance Invoice

The Constraint: Why Naive Implementations Fail

The tutorial version of workflow management is straightforward: add a status column to your database table, write some conditional logic, and update the field when things happen. This works for the first version. It breaks when reality arrives.

The spreadsheet-to-software trap

Processes that "work" in spreadsheets and email hide enormous complexity. This is a common symptom of when spreadsheets break. The first version of the software works. Then come the exceptions:

  • "But sometimes we skip that step"
  • "Unless it's over 10,000 pounds, then it needs director approval"
  • "We can reverse it, but only within 24 hours"
  • "If it's from a preferred supplier, the payment terms are different"
  • "Marketing approvals need sign-off from legal if there's pricing involved"

Each exception spawns an if-statement. Each if-statement interacts with others. Within months, you have a codebase where no one can confidently answer "what happens if I do X in state Y?"

What makes business workflows genuinely complex

Real business processes exhibit patterns that simple status fields cannot express:

  • State transitions with conditions: Not linear flows but branching paths based on data, context, or actor permissions
  • Approval chains: Multiple people must approve, sometimes in sequence, sometimes in parallel, sometimes with quorum rules
  • Time-based triggers: Deadlines, escalations, reminders, auto-transitions after inactivity
  • Parallel paths: Multiple activities happening simultaneously, rejoining at synchronisation points
  • Exceptions and reversals: Cancellations, corrections, partial rollbacks, edge cases
  • External dependencies: Workflows that pause waiting for external systems or human input

The cost of getting this wrong

Failed workflow implementations manifest in predictable ways:

Brittle systems needing constant developer intervention for "impossible" state combinations
Workarounds that live outside the system, defeating the purpose of automation
Shadow processes where teams revert to spreadsheets and email
Lost audit trails because state changes happen through direct database updates
Untestable logic scattered across controllers, models, and services
Fear of change because no one knows what side effects a modification might trigger

The Naive Approach: Status Fields and Scattered Logic

The anti-pattern we see most often starts innocently. A purchase order model gains a status field with a few possible values. Business logic checks this field before performing actions.

// The naive approach - status field with scattered checks
class PurchaseOrder extends Model
{
    const STATUS_DRAFT = 'draft';
    const STATUS_SUBMITTED = 'submitted';
    const STATUS_APPROVED = 'approved';
    const STATUS_REJECTED = 'rejected';
    const STATUS_FULFILLED = 'fulfilled';

    public function submit()
    {
        if ($this->status !== self::STATUS_DRAFT) {
            throw new InvalidStateException('Can only submit draft orders');
        }

        $this->status = self::STATUS_SUBMITTED;
        $this->save();

        // Send notification
        Mail::to($this->approver)->send(new OrderSubmittedNotification($this));
    }

    public function approve()
    {
        if ($this->status !== self::STATUS_SUBMITTED) {
            throw new InvalidStateException('Can only approve submitted orders');
        }

        // But wait - what about the 10k rule?
        if ($this->total > 10000 && !$this->hasDirectorApproval()) {
            throw new InvalidStateException('Orders over 10k need director approval');
        }

        // And the preferred supplier exception?
        if ($this->supplier->isPreferred() && $this->total < 5000) {
            // Skip straight to fulfilled for small preferred supplier orders
            $this->status = self::STATUS_FULFILLED;
        } else {
            $this->status = self::STATUS_APPROVED;
        }

        $this->save();

        // More notifications, maybe trigger inventory...
    }
}

This pattern has several failure modes:

Transition logic scattered across the codebase

State change rules live wherever someone needed to change state. Controllers, services, event handlers, console commands. No single source of truth for "what transitions are allowed."

Guards and actions tightly coupled

The check for whether a transition is allowed is mixed with the code that performs the transition. Testing guards requires executing the full transition logic.

Implicit state machine

The valid states and transitions exist only implicitly, spread across if-statements. There's no way to generate a diagram of allowed flows or validate that the implementation matches the business requirements.

No transition history

Only the current status is stored. When something goes wrong, there's no record of how the entity reached its current state or who triggered each transition.

The fundamental problem: the code doesn't model the workflow. It implements ad-hoc responses to workflow-adjacent needs. As requirements evolve, the gap between what the business thinks the process is and what the code actually does grows wider. Complete audit trails become impossible when state changes happen through scattered conditionals.


The Robust Pattern: Explicit State Machines

A proper workflow engine makes the state machine explicit. States, transitions, guards, and actions are declared, not inferred from scattered conditionals.

States

Named conditions an entity can be in. Exhaustive and mutually exclusive. An order is Draft, Submitted, PendingDirectorApproval, Approved, Rejected, Fulfilled, or Cancelled. Never two at once. Never an unnamed state.

The set of states is the vocabulary for discussing the workflow. If two people disagree about what "approved" means, the state machine definition settles it.

Transitions

Named movements between states. Each transition has a source state (or states), a target state, and a name. The transition "submit" moves from Draft to Submitted. The transition "approve" moves from Submitted to Approved (or to PendingDirectorApproval if conditions apply).

Transitions that aren't defined cannot happen. The model enforces the rules.

Guards

Functions that determine whether a transition is allowed. Guards check preconditions: user permissions, data validity, business rules, time constraints. A guard returns true or false. If false, the transition is blocked.

Guards are pure functions. They inspect state but don't modify it. This makes them trivially testable in isolation.

Actions

Side effects triggered by transitions. Send an email. Create a task. Update inventory. Log an audit entry. Actions execute after the transition succeeds. They can be synchronous or queued for background processing.

Actions are decoupled from transition logic. The workflow engine handles the state change; actions handle the consequences.

The purchase order workflow, properly modelled

Here's how the same purchase order workflow looks when expressed as an explicit state machine. This example uses a Laravel state machine package like Spatie Laravel Model States, but the concepts apply regardless of framework.

// config/state-machines/purchase-order.php
return [
    'initial' => 'draft',

    'states' => [
        'draft',
        'submitted',
        'pending_director_approval',
        'approved',
        'rejected',
        'fulfilled',
        'cancelled',
    ],

    'transitions' => [
        'submit' => [
            'from' => ['draft'],
            'to' => 'submitted',
            'guards' => [HasRequiredFieldsGuard::class],
            'actions' => [NotifyApproverAction::class],
        ],

        'approve' => [
            'from' => ['submitted'],
            'to' => 'approved',
            'guards' => [
                UserCanApproveGuard::class,
                WithinApprovalLimitGuard::class,
            ],
            'actions' => [
                CreateFulfillmentTaskAction::class,
                NotifyRequesterAction::class,
            ],
        ],

        'escalate_to_director' => [
            'from' => ['submitted'],
            'to' => 'pending_director_approval',
            'guards' => [ExceedsApprovalLimitGuard::class],
            'actions' => [NotifyDirectorAction::class],
        ],

        'director_approve' => [
            'from' => ['pending_director_approval'],
            'to' => 'approved',
            'guards' => [UserIsDirectorGuard::class],
            'actions' => [
                CreateFulfillmentTaskAction::class,
                NotifyRequesterAction::class,
            ],
        ],

        'reject' => [
            'from' => ['submitted', 'pending_director_approval'],
            'to' => 'rejected',
            'guards' => [UserCanRejectGuard::class],
            'actions' => [NotifyRequesterOfRejectionAction::class],
        ],

        'fulfill' => [
            'from' => ['approved'],
            'to' => 'fulfilled',
            'guards' => [InventoryAvailableGuard::class],
            'actions' => [
                DeductInventoryAction::class,
                CreateReceiptAction::class,
            ],
        ],

        'cancel' => [
            'from' => ['draft', 'submitted', 'pending_director_approval'],
            'to' => 'cancelled',
            'guards' => [UserCanCancelGuard::class],
            'actions' => [NotifyStakeholdersOfCancellationAction::class],
        ],
    ],
];

This configuration is the single source of truth for the workflow. You can read it and understand every possible state, every allowed transition, what conditions must be met, and what happens when transitions occur.

Guard implementation

Guards are small, focused classes that answer a single question: is this transition allowed?

class WithinApprovalLimitGuard implements Guard
{
    public function check(PurchaseOrder $order, User $actor): bool
    {
        $limit = $actor->approval_limit ?? 0;

        return $order->total <= $limit;
    }

    public function message(): string
    {
        return 'Order total exceeds your approval limit.';
    }
}

class ExceedsApprovalLimitGuard implements Guard
{
    public function check(PurchaseOrder $order, User $actor): bool
    {
        $limit = $actor->approval_limit ?? 0;

        return $order->total > $limit;
    }

    public function message(): string
    {
        return 'Order requires director approval.';
    }
}

These guards are trivially testable. Create a purchase order, create a user, call the check method, assert the result. No database setup for unrelated entities. No complex mocking. No side effects.

Action implementation

Actions handle side effects. They're decoupled from the transition decision, making them easier to test and modify independently.

class NotifyApproverAction implements Action
{
    public function execute(PurchaseOrder $order, Transition $transition): void
    {
        $approver = $this->determineApprover($order);

        Mail::to($approver)
            ->queue(new OrderSubmittedForApproval($order));
    }

    private function determineApprover(PurchaseOrder $order): User
    {
        // Approver assignment logic
        return $order->department->approver
            ?? $order->requester->manager;
    }
}

class DeductInventoryAction implements Action
{
    public function execute(PurchaseOrder $order, Transition $transition): void
    {
        foreach ($order->lineItems as $item) {
            $item->product->decrementStock($item->quantity);
        }

        event(new InventoryDeducted($order));
    }
}

Concrete Example: Multi-Level Approval Chain

Approval chains are where naive implementations fail most spectacularly. Consider a content approval workflow for a marketing team:

  • Junior marketers create content
  • Senior marketers review for brand voice
  • Legal reviews anything mentioning pricing, competitors, or claims
  • Directors approve high-visibility campaigns
  • Approvers can request changes, which sends content back for revision
  • The original creator can withdraw content at any stage
Draft

Being created

Brand Review

Senior check

Legal Review

If required

Director Review

High visibility

Approved

Ready to publish

The state machine for this workflow:

return [
    'initial' => 'draft',

    'states' => [
        'draft',
        'pending_brand_review',
        'pending_legal_review',
        'pending_director_review',
        'changes_requested',
        'approved',
        'published',
        'withdrawn',
    ],

    'transitions' => [
        'submit_for_review' => [
            'from' => ['draft', 'changes_requested'],
            'to' => 'pending_brand_review',
            'guards' => [ContentCompleteGuard::class],
        ],

        'brand_approve' => [
            'from' => ['pending_brand_review'],
            'to' => 'pending_legal_review',
            'guards' => [
                UserHasRoleGuard::class => ['role' => 'senior_marketer'],
                RequiresLegalReviewGuard::class,
            ],
        ],

        'brand_approve_skip_legal' => [
            'from' => ['pending_brand_review'],
            'to' => 'pending_director_review',
            'guards' => [
                UserHasRoleGuard::class => ['role' => 'senior_marketer'],
                DoesNotRequireLegalReviewGuard::class,
                RequiresDirectorReviewGuard::class,
            ],
        ],

        'brand_approve_final' => [
            'from' => ['pending_brand_review'],
            'to' => 'approved',
            'guards' => [
                UserHasRoleGuard::class => ['role' => 'senior_marketer'],
                DoesNotRequireLegalReviewGuard::class,
                DoesNotRequireDirectorReviewGuard::class,
            ],
        ],

        'legal_approve' => [
            'from' => ['pending_legal_review'],
            'to' => 'pending_director_review',
            'guards' => [
                UserHasRoleGuard::class => ['role' => 'legal'],
                RequiresDirectorReviewGuard::class,
            ],
        ],

        'legal_approve_final' => [
            'from' => ['pending_legal_review'],
            'to' => 'approved',
            'guards' => [
                UserHasRoleGuard::class => ['role' => 'legal'],
                DoesNotRequireDirectorReviewGuard::class,
            ],
        ],

        'director_approve' => [
            'from' => ['pending_director_review'],
            'to' => 'approved',
            'guards' => [UserHasRoleGuard::class => ['role' => 'director']],
        ],

        'request_changes' => [
            'from' => [
                'pending_brand_review',
                'pending_legal_review',
                'pending_director_review',
            ],
            'to' => 'changes_requested',
            'guards' => [UserCanReviewGuard::class],
            'actions' => [NotifyCreatorOfChangesAction::class],
        ],

        'withdraw' => [
            'from' => [
                'draft',
                'pending_brand_review',
                'pending_legal_review',
                'pending_director_review',
                'changes_requested',
            ],
            'to' => 'withdrawn',
            'guards' => [UserIsCreatorGuard::class],
        ],

        'publish' => [
            'from' => ['approved'],
            'to' => 'published',
            'guards' => [UserCanPublishGuard::class],
            'actions' => [
                PublishContentAction::class,
                NotifyTeamOfPublicationAction::class,
            ],
        ],
    ],
];

The guards that determine routing based on content characteristics:

class RequiresLegalReviewGuard implements Guard
{
    private array $legalTriggers = [
        'pricing',
        'price',
        'cost',
        'competitor',
        'guarantee',
        'warranty',
        'claim',
    ];

    public function check(Content $content, User $actor): bool
    {
        $text = strtolower($content->body . ' ' . $content->title);

        foreach ($this->legalTriggers as $trigger) {
            if (str_contains($text, $trigger)) {
                return true;
            }
        }

        return $content->includes_testimonial
            || $content->includes_statistics;
    }
}

class RequiresDirectorReviewGuard implements Guard
{
    public function check(Content $content, User $actor): bool
    {
        return $content->campaign?->budget > 50000
            || $content->campaign?->visibility === 'high'
            || $content->target_channels->contains('homepage');
    }
}

Notice how the workflow branches automatically based on content characteristics. No conditional logic in controllers. No "if this then that" scattered through the codebase. The state machine configuration is readable documentation of the business process.


Long-Running Workflows and Persistence

Some workflows complete in seconds. A form submission triggers validation, saves data, sends a notification. Others take days, weeks, or months. A procurement process might span from initial request to final delivery over several months, with human tasks, external system calls, and waiting periods throughout.

Long-running workflows require different handling:

State persistence

The workflow state must survive application restarts, deployments, and server failures. Store the current state, the history of transitions, and any context data needed to resume. For database-backed workflows, this typically means a dedicated state column plus a transitions history table.

-- Core state on the entity
ALTER TABLE purchase_orders ADD COLUMN workflow_state VARCHAR(50);

-- Transition history for audit
CREATE TABLE workflow_transitions (
    id BIGINT PRIMARY KEY,
    entity_type VARCHAR(100),
    entity_id BIGINT,
    from_state VARCHAR(50),
    to_state VARCHAR(50),
    transition_name VARCHAR(50),
    actor_id BIGINT,
    context JSON,
    created_at TIMESTAMP
);
Scheduled transitions

Many workflows need time-based transitions: auto-escalation after 48 hours of inactivity, reminder notifications at set intervals, automatic closure of stale items. These require a scheduler that checks for pending time-based transitions.

// Scheduled command that runs every hour
class ProcessScheduledTransitions extends Command
{
    public function handle(): void
    {
        // Auto-escalate stale approvals
        PurchaseOrder::query()
            ->where('workflow_state', 'submitted')
            ->where('state_entered_at', '<', now()->subHours(48))
            ->each(function ($order) {
                $order->workflow()->transition('auto_escalate');
            });

        // Send reminders for pending reviews
        Content::query()
            ->whereIn('workflow_state', [
                'pending_brand_review',
                'pending_legal_review',
            ])
            ->where('state_entered_at', '<', now()->subHours(24))
            ->where('last_reminder_at', '<', now()->subHours(24))
            ->each(function ($content) {
                $content->sendReviewReminder();
            });
    }
}
Idempotency

Long-running workflows must handle duplicate transition attempts gracefully. A user clicks "approve" twice. A webhook fires multiple times. A scheduled job runs during a deployment and restarts. Guard checks and state verification ensure that attempting an invalid transition fails safely rather than corrupting data.

public function attemptTransition(string $transition): TransitionResult
{
    return DB::transaction(function () use ($transition) {
        // Lock the row to prevent concurrent transitions
        $entity = $this->entity->lockForUpdate()->fresh();

        // Verify the transition is still valid
        if (!$this->canTransition($entity, $transition)) {
            return TransitionResult::blocked(
                'Transition no longer available'
            );
        }

        // Execute the transition
        return $this->executeTransition($entity, $transition);
    });
}

Workflow Versioning

Business processes evolve. The approval chain that made sense last year needs an additional step this year. A new compliance requirement mandates legal review for content that previously didn't need it. The challenge: hundreds or thousands of in-flight items are mid-workflow when the process changes.

Strategies for workflow evolution

Additive changes

Adding new states or transitions that don't affect existing paths. A new "urgent" transition that bypasses certain steps. An additional optional approval for high-value items. These changes are safe to deploy immediately.

Rule: new paths that don't intersect existing paths need no migration.

Breaking changes

Removing states, changing transition targets, adding mandatory new steps in existing paths. These require migration strategies for in-flight items.

Rule: changes that affect paths items are currently traversing require explicit handling.

Version tagging

For complex workflows with frequent changes, version the workflow definition and store the version with each entity.

// Each entity tracks which workflow version it's using
class PurchaseOrder extends Model
{
    protected $casts = [
        'workflow_version' => 'integer',
    ];

    public function getWorkflowConfig(): array
    {
        return config(
            "workflows.purchase-order.v{$this->workflow_version}"
        );
    }
}

// New items get the current version
class CreatePurchaseOrder
{
    public function handle(array $data): PurchaseOrder
    {
        return PurchaseOrder::create([
            ...$data,
            'workflow_version' => config('workflows.purchase-order.current'),
            'workflow_state' => 'draft',
        ]);
    }
}

// Migration path for in-flight items
class MigrateWorkflowVersion extends Command
{
    public function handle(): void
    {
        PurchaseOrder::query()
            ->where('workflow_version', 1)
            ->whereIn('workflow_state', ['draft', 'submitted'])
            ->update([
                'workflow_version' => 2,
                // Map old states to new states if needed
            ]);

        // Items in terminal states stay on old version
        // They're complete, no need to migrate
    }
}

State mapping for migrations

When workflow changes require state mapping, be explicit about the transformation:

class WorkflowMigration
{
    // V1 had: submitted -> approved
    // V2 adds: submitted -> pending_compliance -> approved

    private array $stateMapping = [
        // Items in 'submitted' stay in 'submitted'
        // They'll go through compliance on next transition
        'submitted' => 'submitted',

        // Items already approved skip the new step
        'approved' => 'approved',

        // Terminal states unchanged
        'fulfilled' => 'fulfilled',
        'cancelled' => 'cancelled',
    ];

    public function migrate(PurchaseOrder $order): void
    {
        $newState = $this->stateMapping[$order->workflow_state]
            ?? throw new UnmappedStateException($order->workflow_state);

        $order->update([
            'workflow_version' => 2,
            'workflow_state' => $newState,
        ]);

        // Log the migration for audit
        $order->logTransition(
            from: $order->workflow_state,
            to: $newState,
            transition: 'version_migration',
            context: ['from_version' => 1, 'to_version' => 2]
        );
    }
}

Human Tasks and Notifications

Most workflows involve human decisions. Someone must review and approve. Someone must acknowledge receipt. Someone must make a judgment call. The workflow engine must integrate cleanly with task management and notification systems. For operations teams, this ties directly into project visibility and workload management.

Task generation

When a workflow enters a state requiring human action, create an explicit task:

class CreateApprovalTaskAction implements Action
{
    public function execute(PurchaseOrder $order, Transition $transition): void
    {
        $approver = $this->determineApprover($order);

        Task::create([
            'type' => 'purchase_order_approval',
            'title' => "Approve PO #{$order->number}",
            'description' => $this->buildDescription($order),
            'assignee_id' => $approver->id,
            'due_at' => now()->addHours(48),
            'priority' => $this->calculatePriority($order),
            'taskable_type' => PurchaseOrder::class,
            'taskable_id' => $order->id,
            'context' => [
                'available_transitions' => ['approve', 'reject', 'escalate'],
                'workflow_state' => $order->workflow_state,
            ],
        ]);
    }

    private function calculatePriority(PurchaseOrder $order): string
    {
        if ($order->is_urgent) return 'high';
        if ($order->total > 10000) return 'high';
        if ($order->needed_by < now()->addDays(7)) return 'medium';
        return 'normal';
    }
}

Task resolution

Completing a task triggers the corresponding workflow transition:

class TaskController extends Controller
{
    public function complete(Task $task, CompleteTaskRequest $request)
    {
        $transition = $request->validated('transition');
        $comments = $request->validated('comments');

        // Attempt the workflow transition
        $result = $task->taskable
            ->workflow()
            ->transition($transition, [
                'actor' => auth()->user(),
                'comments' => $comments,
            ]);

        if ($result->blocked()) {
            return back()->withErrors([
                'transition' => $result->blockedReason(),
            ]);
        }

        // Mark task complete
        $task->complete($transition, $comments);

        return redirect()->route('tasks.index')
            ->with('success', 'Task completed');
    }
}

Notification strategy

Notifications should be timely but not overwhelming. A typical pattern:

Event Notification Channel
Task assigned Immediate Email + in-app
Approaching due date 24 hours before In-app only
Overdue Daily digest Email
Escalated Immediate Email + in-app + Slack
Completed by someone else Immediate In-app only
class WorkflowNotificationService
{
    public function notifyAssignee(Task $task): void
    {
        $task->assignee->notify(new TaskAssigned($task));
    }

    public function sendDueDateReminders(): void
    {
        Task::query()
            ->pending()
            ->where('due_at', '<=', now()->addHours(24))
            ->where('due_at', '>', now())
            ->whereNull('reminder_sent_at')
            ->each(function ($task) {
                $task->assignee->notify(new TaskDueSoon($task));
                $task->update(['reminder_sent_at' => now()]);
            });
    }

    public function sendOverdueDigest(): void
    {
        $tasksByAssignee = Task::query()
            ->pending()
            ->where('due_at', '<', now())
            ->get()
            ->groupBy('assignee_id');

        foreach ($tasksByAssignee as $assigneeId => $tasks) {
            User::find($assigneeId)
                ->notify(new OverdueTasksDigest($tasks));
        }
    }
}

Monitoring and Debugging

Workflow systems fail in ways that status fields don't. An item stuck in a state because a guard unexpectedly returns false. A transition that should have triggered but didn't. An action that failed silently. Observability is essential.

Transition logging

Every transition attempt should be logged, successful or not:

class TransitionLogger
{
    public function logAttempt(
        Model $entity,
        string $transition,
        User $actor,
        bool $successful,
        ?string $blockedReason = null,
        array $context = []
    ): void {
        WorkflowLog::create([
            'entity_type' => get_class($entity),
            'entity_id' => $entity->id,
            'transition' => $transition,
            'from_state' => $entity->workflow_state,
            'to_state' => $successful
                ? $this->getTargetState($entity, $transition)
                : null,
            'actor_id' => $actor->id,
            'successful' => $successful,
            'blocked_reason' => $blockedReason,
            'context' => $context,
            'guard_results' => $this->lastGuardResults,
            'created_at' => now(),
        ]);
    }
}

Workflow dashboard metrics

Track workflow health with key metrics:

Items by state
How many items are in each state right now? Spikes indicate bottlenecks.
Time in state
Average and 95th percentile time items spend in each state. Long times indicate stuck processes.
Transition success rate
Percentage of transition attempts that succeed. Low rates indicate guard problems or user confusion.
class WorkflowMetrics
{
    public function getStateDistribution(string $entityType): Collection
    {
        return DB::table($this->getTable($entityType))
            ->select('workflow_state', DB::raw('count(*) as count'))
            ->whereNotIn('workflow_state', ['fulfilled', 'cancelled'])
            ->groupBy('workflow_state')
            ->get();
    }

    public function getAverageTimeInState(
        string $entityType,
        string $state
    ): float {
        return WorkflowLog::query()
            ->where('entity_type', $entityType)
            ->where('from_state', $state)
            ->where('successful', true)
            ->avg(
                DB::raw('TIMESTAMPDIFF(HOUR, state_entered_at, created_at)')
            );
    }

    public function getTransitionSuccessRate(
        string $entityType,
        string $transition,
        Carbon $since
    ): float {
        $attempts = WorkflowLog::query()
            ->where('entity_type', $entityType)
            ->where('transition', $transition)
            ->where('created_at', '>=', $since)
            ->count();

        $successes = WorkflowLog::query()
            ->where('entity_type', $entityType)
            ->where('transition', $transition)
            ->where('created_at', '>=', $since)
            ->where('successful', true)
            ->count();

        return $attempts > 0 ? ($successes / $attempts) * 100 : 0;
    }
}

Debugging stuck items

When an item is stuck, the debugging process:

1

Check the transition log. What was the last attempted transition? Did it fail? What was the blocked reason?


2

Inspect guard results. Which guards passed and failed? What values did they evaluate?


3

Check for missing tasks. Is there a task assigned? Is the assignee aware? Has the notification been sent?


4

Verify external dependencies. If the workflow is waiting on an external system, check integration logs.

class WorkflowDebugger
{
    public function diagnose(Model $entity): DiagnosticReport
    {
        return new DiagnosticReport([
            'current_state' => $entity->workflow_state,
            'time_in_state' => $entity->state_entered_at->diffForHumans(),
            'available_transitions' => $this->getAvailableTransitions($entity),
            'blocked_transitions' => $this->getBlockedTransitions($entity),
            'pending_tasks' => $entity->tasks()->pending()->get(),
            'recent_logs' => $entity->workflowLogs()->latest()->limit(10)->get(),
            'guard_evaluations' => $this->evaluateAllGuards($entity),
        ]);
    }

    private function getBlockedTransitions(Model $entity): array
    {
        $blocked = [];

        foreach ($this->getAllTransitions($entity) as $transition) {
            $result = $entity->workflow()->canTransition($transition);

            if (!$result->allowed) {
                $blocked[$transition] = [
                    'reason' => $result->reason,
                    'failed_guards' => $result->failedGuards,
                ];
            }
        }

        return $blocked;
    }
}

Testing Workflow Logic

Workflow logic is surprisingly hard to test well. State combinations multiply quickly. A workflow with 8 states and 12 transitions has dozens of valid paths and hundreds of invalid ones. Testing every combination exhaustively is impractical. Testing nothing is negligent.

Unit testing guards

Guards are pure functions. Test them in isolation:

class WithinApprovalLimitGuardTest extends TestCase
{
    #[Test]
    public function it_allows_when_order_within_limit(): void
    {
        $order = PurchaseOrder::factory()->make(['total' => 5000]);
        $user = User::factory()->make(['approval_limit' => 10000]);

        $guard = new WithinApprovalLimitGuard();

        $this->assertTrue($guard->check($order, $user));
    }

    #[Test]
    public function it_blocks_when_order_exceeds_limit(): void
    {
        $order = PurchaseOrder::factory()->make(['total' => 15000]);
        $user = User::factory()->make(['approval_limit' => 10000]);

        $guard = new WithinApprovalLimitGuard();

        $this->assertFalse($guard->check($order, $user));
    }

    #[Test]
    public function it_blocks_when_user_has_no_limit(): void
    {
        $order = PurchaseOrder::factory()->make(['total' => 100]);
        $user = User::factory()->make(['approval_limit' => null]);

        $guard = new WithinApprovalLimitGuard();

        $this->assertFalse($guard->check($order, $user));
    }
}

Testing transitions

Test that transitions move between expected states and trigger expected actions:

class PurchaseOrderWorkflowTest extends TestCase
{
    #[Test]
    public function submit_moves_from_draft_to_submitted(): void
    {
        $order = PurchaseOrder::factory()
            ->draft()
            ->complete()
            ->create();

        $order->workflow()->transition('submit');

        $this->assertEquals('submitted', $order->fresh()->workflow_state);
    }

    #[Test]
    public function submit_sends_notification_to_approver(): void
    {
        Notification::fake();

        $order = PurchaseOrder::factory()->draft()->complete()->create();
        $approver = $order->department->approver;

        $order->workflow()->transition('submit');

        Notification::assertSentTo($approver, OrderSubmittedForApproval::class);
    }

    #[Test]
    public function cannot_submit_incomplete_order(): void
    {
        $order = PurchaseOrder::factory()
            ->draft()
            ->incomplete()
            ->create();

        $result = $order->workflow()->transition('submit');

        $this->assertTrue($result->blocked());
        $this->assertEquals('draft', $order->fresh()->workflow_state);
    }
}

Scenario testing

Test complete paths through the workflow:

class PurchaseOrderScenarioTest extends TestCase
{
    #[Test]
    public function standard_approval_path(): void
    {
        // Setup
        $requester = User::factory()->create();
        $approver = User::factory()->withApprovalLimit(10000)->create();
        $order = PurchaseOrder::factory()
            ->for($requester, 'requester')
            ->draft()
            ->withTotal(5000)
            ->create();

        // Submit
        $this->actingAs($requester);
        $order->workflow()->transition('submit');
        $this->assertEquals('submitted', $order->fresh()->workflow_state);

        // Approve
        $this->actingAs($approver);
        $order->workflow()->transition('approve');
        $this->assertEquals('approved', $order->fresh()->workflow_state);

        // Fulfill
        $order->workflow()->transition('fulfill');
        $this->assertEquals('fulfilled', $order->fresh()->workflow_state);
    }

    #[Test]
    public function high_value_escalation_path(): void
    {
        $requester = User::factory()->create();
        $approver = User::factory()->withApprovalLimit(10000)->create();
        $director = User::factory()->director()->create();
        $order = PurchaseOrder::factory()
            ->for($requester, 'requester')
            ->draft()
            ->withTotal(25000) // Exceeds approver limit
            ->create();

        // Submit
        $this->actingAs($requester);
        $order->workflow()->transition('submit');

        // Approver attempts to approve, but limit exceeded
        $this->actingAs($approver);
        $result = $order->workflow()->transition('approve');
        $this->assertTrue($result->blocked());

        // Escalate instead
        $order->workflow()->transition('escalate_to_director');
        $this->assertEquals('pending_director_approval', $order->fresh()->workflow_state);

        // Director approves
        $this->actingAs($director);
        $order->workflow()->transition('director_approve');
        $this->assertEquals('approved', $order->fresh()->workflow_state);
    }
}

Property-based testing

For complex workflows, property-based testing can find edge cases that example-based tests miss:

class WorkflowPropertyTest extends TestCase
{
    #[Test]
    public function terminal_states_have_no_outgoing_transitions(): void
    {
        $terminalStates = ['fulfilled', 'cancelled'];
        $config = config('workflows.purchase-order');

        foreach ($config['transitions'] as $transition) {
            foreach ($terminalStates as $state) {
                $this->assertNotContains(
                    $state,
                    (array) $transition['from'],
                    "Terminal state {$state} should not have outgoing transitions"
                );
            }
        }
    }

    #[Test]
    public function all_states_are_reachable_from_initial(): void
    {
        $config = config('workflows.purchase-order');
        $reachable = $this->findReachableStates(
            $config['initial'],
            $config['transitions']
        );

        foreach ($config['states'] as $state) {
            $this->assertContains(
                $state,
                $reachable,
                "State {$state} is not reachable from initial state"
            );
        }
    }

    #[Test]
    public function no_dead_ends_except_terminal_states(): void
    {
        $terminalStates = ['fulfilled', 'cancelled'];
        $config = config('workflows.purchase-order');

        foreach ($config['states'] as $state) {
            if (in_array($state, $terminalStates)) continue;

            $hasOutgoing = false;
            foreach ($config['transitions'] as $transition) {
                if (in_array($state, (array) $transition['from'])) {
                    $hasOutgoing = true;
                    break;
                }
            }

            $this->assertTrue(
                $hasOutgoing,
                "Non-terminal state {$state} has no outgoing transitions"
            );
        }
    }
}

When to Build vs Buy

Not every workflow needs a custom engine. The decision depends on complexity, integration requirements, and who needs to modify workflows.

Off-the-shelf workflow tools

Tools like Camunda, Temporal, and various BPM platforms offer sophisticated workflow capabilities. Worth considering when:

  • Workflows are truly complex with many actors and systems
  • Business users need to modify workflows without developers
  • You need visual workflow modelling and BPMN compliance
  • Cross-system orchestration is primary use case
Custom workflow logic

Build custom when:

  • Your processes don't fit standard workflow tool paradigms
  • Deep integration with your data model is critical
  • Performance requirements are demanding
  • You need complete control over the implementation
  • The overhead of an external tool exceeds its benefits
The middle ground

Libraries and frameworks within your application. Laravel has state machine packages (spatie/laravel-model-states, asantibanez/laravel-eloquent-state-machines). Most frameworks have similar options. You get structure without external dependencies or operational overhead.

Consideration External Tool Framework Library Custom Built
Setup time Days to weeks Hours Days
Learning curve Steep Gentle Moderate
Flexibility Within tool constraints Good Complete
Business user editing Built-in Not available Must build
Operational overhead Additional service None None
Data model integration Requires adaptation Native Native

The Business Case

Why does this architectural complexity matter? Because business processes are where software meets reality, and reality is messy.

  • Handle complexity Approvals, exceptions, and parallel paths modelled cleanly without if-statement sprawl
  • Enforce rules at the code level Invalid transitions are impossible, not just discouraged. The system prevents invalid states.
  • Stay maintainable Changes to workflows modify configuration, not scattered business logic. New requirements don't require archaeology.
  • Scale with the business New states and transitions add to the configuration. Existing flows continue working.
  • Provide visibility Clear audit trails of what happened, when, and why. Debugging is reading logs, not guessing.
  • Enable testing Guards, actions, and transitions are testable units. Confidence in changes comes from tests, not hope.

The investment in proper workflow modelling pays off when the third "but sometimes we need to..." arrives. Instead of another if-statement in an already-complex method, you add a guard and a transition to a configuration file. The system remains comprehensible. The team remains productive. The business gets what it needs without accumulating technical debt.


Build Your Workflow Engine

Complex business processes modelled in code. Approvals, exceptions, conditional logic: handled cleanly without unmaintainable tangles of if-statements. Systems that enforce rules and adapt as your processes evolve.

Discuss your business processes →
Graphic Swish