moffhub/flow

Database-driven state machine and workflow engine for Laravel. Multi-step approval gates, role-based guards, auditable transitions, and configurable actions for government revenue systems.

Maintainers

Package info

github.com/Moffhub-Solutions/flow

Homepage

Issues

pkg:composer/moffhub/flow

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v0.0.1 2026-03-30 00:46 UTC

This package is auto-updated.

Last update: 2026-04-16 21:59:34 UTC


README

Database-driven state machine and workflow engine for Laravel. Multi-step approval gates, role-based guards, auditable transitions, configurable actions, parallel states, scheduled transitions, a visual builder API, and workflow visualization — built for government revenue systems but applicable to any multi-step business process.

Table of Contents

Features

  • Database-driven workflow definitions — non-developers configure workflows via API, visual builder, or JSON seed files. No code changes required.
  • Multi-step approval gates — N-of-M role-based approvals with configurable rejection policies (any or majority), expiry timers, and role escalation.
  • Guard system — role, permission, JSON condition, and custom guard classes run before every transition. If any guard fails, the transition is blocked.
  • Action system — built-in actions (billing, SMS, email, documents, notifications) and custom action classes run after successful transitions.
  • Immutable audit trail — every transition logged with performer, comment, approval records, and model attribute diffs.
  • Attribute change tracking — automatically captures which model fields changed during each transition with old/new values.
  • Parallel states (split/join) — models can be in multiple states simultaneously for concurrent review branches.
  • Scheduled transitions — auto-transition at a future time with a queue job that processes due transitions.
  • Query scopes — Eloquent scopes like whereWorkflowState('approved'), whereWorkflowOverdue(), whereWorkflowAssignedTo($userId).
  • Workflow subscriber pattern — convention-based onEnterApproved(), onLeaveDraft(), onTransitionSubmit() event handling.
  • Workflow visualization — generate Mermaid or Graphviz DOT diagrams from definitions via Artisan command.
  • Visual builder API — full block editor backend with canvas save, bulk layout updates, export/import for drag-and-drop workflow builders.
  • Full REST API — CRUD endpoints for definitions, states, transitions, instances, history, approvals.
  • Fluent facadeFlow::for($model)->currentState(), ->transitionTo(), ->history().
  • Artisan commandsflow:seed (import JSON), flow:status (inspect workflows), flow:visualize (generate diagrams).
  • Soft deletes — definitions, states, and transitions are soft-deleted to preserve audit trail integrity.

Requirements

  • PHP 8.4+
  • Laravel 12.x or 13.x

Installation

composer require moffhub/flow

Publish and run migrations:

php artisan vendor:publish --tag=flow-migrations
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=flow-config

Quick Start

1. Apply the trait to your model

use Moffhub\Flow\Contracts\StatefulModel;
use Moffhub\Flow\Traits\HasWorkflow;

class BusinessPermit extends Model implements StatefulModel
{
    use HasWorkflow;

    // Optional: implement methods for built-in actions
    public function createBill(): void { /* ... */ }
    public function generateDocument(string $state): void { /* ... */ }
    public function sendWorkflowSms(WorkflowTransition $transition): void { /* ... */ }
    public function sendWorkflowEmail(WorkflowTransition $transition): void { /* ... */ }
}

When a BusinessPermit is created, the HasWorkflow trait automatically looks up the active workflow definition for its model type and initializes a workflow instance in the initial_state.

2. Create a workflow definition

Option A: Via API

POST /api/workflows/definitions
{
    "name": "Business Permit Workflow",
    "code": "business_permit",
    "model_type": "App\\Models\\BusinessPermit",
    "initial_state": "draft"
}

Option B: Via JSON seed file

php artisan flow:seed workflow.json

Option C: Via visual builder (see Visual Builder API)

3. Add states and transitions

POST /api/workflows/definitions/{id}/states
{"name": "draft", "label": "Draft", "type": "initial", "color": "#94a3b8"}

POST /api/workflows/definitions/{id}/states
{"name": "submitted", "label": "Submitted", "type": "intermediate", "color": "#3b82f6"}

POST /api/workflows/definitions/{id}/states
{"name": "approved", "label": "Approved", "type": "final", "color": "#22c55e"}

POST /api/workflows/definitions/{id}/transitions
{
    "name": "submit",
    "label": "Submit Application",
    "from_state": "draft",
    "to_state": "submitted"
}

4. Use it in code

$permit = BusinessPermit::create(['name' => 'Cafe Permit', ...]);

$permit->workflowState();           // 'draft'
$permit->availableTransitions();    // Collection of WorkflowTransition models
$permit->canTransition('submit');   // true
$permit->canTransition('approve');  // false (wrong state)
$permit->transition('submit');      // executes → state becomes 'submitted'
$permit->workflowHistory();         // Collection of WorkflowHistory records
$permit->isWorkflowComplete();      // false (not in terminal state)
$permit->isWorkflowOverdue();       // false

Core Concepts

WorkflowDefinition  ← blueprint (states + transitions), stored in DB
    ├── WorkflowState       ← named node: initial | intermediate | final | failed
    └── WorkflowTransition  ← directed edge with guards, actions, approval config

WorkflowInstance    ← runtime: tracks one model's progression through a definition
WorkflowHistory     ← immutable audit log of every transition that occurred
WorkflowApproval    ← multi-role approval gate tracking (pending/approved/rejected)
ScheduledTransition ← future auto-transitions processed by a queue job

State types:

Type Meaning
initial Starting state. The [*] node in diagrams.
intermediate Normal processing state.
final Successfully completed. Terminal — no further transitions allowed.
failed Ended in failure. Terminal — no further transitions allowed.

Golden rules:

  1. Every state change is a transition — no direct state mutation.
  2. Every transition is logged — immutable audit trail.
  3. Guards run before — role, permission, condition checks block invalid transitions.
  4. Actions run after — side-effects only happen on successful state changes.
  5. Approvals are gates — N-of-M roles must approve before the transition executes.

Transition Lifecycle

Every call to $model->transition('approve', comment: 'Looks good') follows this lifecycle:

┌─ BEFORE (Guards) ────────────────────────────┐
│  1. Validate from_state matches current      │
│  2. Check requires_comment                   │
│  3. Run role guards                          │
│  4. Run permission guards                    │
│  5. Run condition guards (JSON operators)    │
│  6. Run custom TransitionGuard classes       │
│  └── Any failure → TransitionDeniedException │
└──────────────────────────────────────────────┘
                    │
                    ▼
┌─ DURING (Approval Gate, if enabled) ─────────┐
│  1. Record user's approval vote              │
│  2. Count approved vs required               │
│  3. If not enough → ApprovalRequiredException│
│     (event fired, return early)              │
│  4. If enough → proceed to execute           │
└──────────────────────────────────────────────┘
                    │
                    ▼
┌─ EXECUTE (DB Transaction) ───────────────────┐
│  1. Capture model attribute changes (diff)   │
│  2. Update instance: current_state,          │
│     previous_state, state_entered_at         │
│  3. Create WorkflowHistory record            │
│  4. Clear approval records for transition    │
└──────────────────────────────────────────────┘
                    │
                    ▼
┌─ AFTER (Actions + Events) ───────────────────┐
│  1. Run built-in actions (bill, sms, etc.)   │
│  2. Run custom TransitionAction classes      │
│  3. Fire WorkflowTransitioned event          │
│  4. Notify WorkflowSubscribers               │
│  5. If terminal → fire WorkflowCompleted     │
└──────────────────────────────────────────────┘

Model Integration

The HasWorkflow Trait

Apply HasWorkflow to any Eloquent model that participates in a workflow:

use Moffhub\Flow\Contracts\StatefulModel;
use Moffhub\Flow\Traits\HasWorkflow;

class BusinessPermit extends Model implements StatefulModel
{
    use HasWorkflow;
}

What the trait provides:

Method Returns Description
workflowInstance() MorphOne The workflow instance relation
workflowState() ?string Current state name ('draft', 'approved', etc.)
availableTransitions() Collection Transitions available from current state
canTransition('name') bool Whether a transition is structurally valid (ignores guards)
transition('name', 'comment', $ctx) WorkflowInstance Execute a transition (runs full lifecycle)
workflowHistory() Collection Immutable audit trail
isWorkflowComplete() bool True if current state is final or failed
isWorkflowOverdue() bool True if deadline has passed
assignWorkflowTo($userId) void Assign a handler
setWorkflowDeadline($date) void Set a deadline
scheduleTransition(...) ScheduledTransition Schedule a future auto-transition

Query scopes provided by the trait (see Query Scopes):

Scope Description
whereWorkflowState('approved') Models in a specific state
whereWorkflowNotState('draft') Models NOT in a state
whereWorkflowStateIn(['a', 'b']) Models in any of the given states
whereWorkflowComplete() Models in terminal states
whereWorkflowOverdue() Models past their deadline
whereWorkflowAssignedTo($id) Models assigned to a user

Auto-Initialization

When a model using HasWorkflow is created, the trait's bootHasWorkflow() method automatically:

  1. Looks up the active WorkflowDefinition matching the model's morph class
  2. Creates a WorkflowInstance in the definition's initial_state

If no matching definition exists, the model is created without a workflow (no error).

Fluent API (Flow Facade)

For a more expressive API, use the Flow facade:

use Moffhub\Flow\Facades\Flow;

// Get a fluent accessor for a model
$flow = Flow::for($permit);

$flow->currentState();                              // 'under_review'
$flow->availableTransitions();                      // Collection<WorkflowTransition>
$flow->canTransition('approve');                    // true
$flow->transitionTo('approve', comment: 'LGTM');   // execute transition
$flow->history();                                   // Collection<WorkflowHistory>
$flow->isComplete();                                // false
$flow->isOverdue();                                 // false
$flow->instance();                                  // WorkflowInstance model

// Direct engine methods via facade
Flow::initialize($model);
Flow::transition($model, 'submit', 'Submitting');
Flow::forceTransition($model, 'rejected', $adminId, 'Override');

Helper Function

// Global helper — returns the WorkflowEngine instance
$engine = flow();
$engine->transition($model, 'approve', 'Looks good');

Guards

Guards run before a transition is allowed. If any guard fails, the transition is blocked with a TransitionDeniedException (HTTP 403).

Role Guards

Configured per transition. The authenticated user must have at least one of the listed roles:

{
    "allowed_roles": ["revenue_officer", "subcounty_officer", "admin"]
}

The engine checks roles by calling $user->hasAnyRole($roles) (Spatie-compatible) or falling back to $user->role attribute matching.

If allowed_roles is empty or null, no role check is performed (any user can trigger the transition).

Permission Guards

The authenticated user must have all listed permissions:

{
    "required_permissions": ["permits.approve", "permits.view"]
}

Checks via $user->hasAllPermissions($permissions) (Spatie-compatible) or $user->can($permission) fallback.

Condition Guards

JSON-based conditions evaluated against the model's attributes. All conditions must pass (AND logic):

{
    "conditions": [
        {"field": "amount_paid", "operator": ">=", "value": 1000},
        {"field": "documents_verified", "operator": "==", "value": true},
        {"field": "inspector_id", "operator": "not_null"},
        {"field": "type", "operator": "in", "value": ["A", "B", "C"]}
    ]
}

Supported operators:

Operator Example Description
== amount == 0 Loose equality
=== status === 'pending' Strict equality
!= status != 'rejected' Not equal
> >= < <= amount >= 1000 Numeric comparison
in type in ['A', 'B'] Value in array
not_in type not_in ['X'] Value not in array
not_null inspector_id not_null Field is not null
is_null rejection_reason is_null Field is null
not_empty documents not_empty Field is truthy

Custom Guard Classes

For complex business logic that can't be expressed as JSON conditions:

<?php

namespace App\Guards;

use Illuminate\Database\Eloquent\Model;
use Moffhub\Flow\Contracts\GuardResult;
use Moffhub\Flow\Contracts\TransitionGuard;
use Moffhub\Flow\Models\WorkflowTransition;

class InspectionPassedGuard implements TransitionGuard
{
    public function check(Model $subject, WorkflowTransition $transition, array $context = []): GuardResult
    {
        if (! $subject->inspection_report_id) {
            return GuardResult::deny('Inspection report has not been submitted.');
        }

        $report = $subject->inspectionReport;

        if ($report->status !== 'passed') {
            return GuardResult::deny("Inspection report status is '{$report->status}', expected 'passed'.");
        }

        return GuardResult::allow();
    }
}

Register in config/flow.php:

'guards' => [
    'inspection_passed' => App\Guards\InspectionPassedGuard::class,
    'documents_complete' => App\Guards\DocumentsCompleteGuard::class,
],

Reference by key in the transition definition:

{"guard_classes": ["inspection_passed", "documents_complete"]}

Guards support Laravel's dependency injection — you can type-hint services in the constructor.

Approval Gates

Multi-Role Approvals

For transitions that require sign-off from multiple roles before execution:

{
    "requires_approval": true,
    "required_approvals": 3,
    "approval_roles": ["ward_revenue_officer", "subcounty_revenue_officer", "liquor_committee_member"]
}

How it works:

  1. User with ward_revenue_officer role calls $permit->transition('approve') → approval recorded (1/3), ApprovalRequiredException thrown
  2. User with subcounty_revenue_officer role calls $permit->transition('approve') → approval recorded (2/3), exception thrown
  3. User with liquor_committee_member role calls $permit->transition('approve') → approval recorded (3/3) → transition executes

Each approval is tracked in the workflow_approvals table with who approved, when, and any comment.

Recording approvals and rejections explicitly:

use Moffhub\Flow\Services\WorkflowEngine;

$engine = app(WorkflowEngine::class);

// Approve
$engine->recordApproval($permit, 'approve', 'Looks good to me');

// Reject
$engine->recordRejection($permit, 'approve', 'Documents are incomplete');

Rejection Policies

Policy Behavior
any (default) One rejection blocks the transition. All pending approvals are also rejected.
majority More than 50% of required approvals must be rejections to block the transition.
{"rejection_policy": "majority"}

Approval Expiry & Escalation

Set a time limit on pending approvals:

{
    "expiry_hours": 72,
    "escalation_role": "admin"
}

When expiry_hours passes, the pending approvals can be escalated to the escalation_role. This is tracked in the transition definition and can be acted on by your application logic via the WorkflowApprovalRequired event.

Actions

Actions run after a transition succeeds. They are side-effects that should only happen on confirmed state changes.

Built-in Actions

Reference these by name in the transition's actions array:

{"actions": ["create_bill", "generate_document", "send_sms", "send_email", "send_notification"]}
Action What It Does
create_bill Calls $model->createBill()
generate_document Calls $model->generateDocument($toState)
send_sms Calls $model->sendWorkflowSms($transition)
send_email Calls $model->sendWorkflowEmail($transition)
send_notification Fires WorkflowNotificationRequired event

Your model implements the corresponding methods — the engine calls them if they exist, silently skips if they don't.

Custom Action Classes

For complex side-effects, create action classes implementing TransitionAction:

<?php

namespace App\Actions;

use Illuminate\Database\Eloquent\Model;
use Moffhub\Flow\Contracts\TransitionAction;
use Moffhub\Flow\Models\WorkflowInstance;
use Moffhub\Flow\Models\WorkflowTransition;

class ScheduleRenewalReminder implements TransitionAction
{
    public function __construct(
        private NotificationService $notifications,
    ) {}

    public function execute(Model $subject, WorkflowInstance $instance, WorkflowTransition $transition): void
    {
        $this->notifications->scheduleReminder(
            user: $subject->applicant,
            message: "Your {$subject->type} permit expires in 30 days.",
            sendAt: $subject->expires_at->subDays(30),
        );
    }
}

Register in config/flow.php:

'actions' => [
    'schedule_renewal_reminder' => App\Actions\ScheduleRenewalReminder::class,
    'create_inspection_report'  => App\Actions\CreateInspectionReport::class,
],

Reference in transition definition:

{"actions": ["create_bill", "schedule_renewal_reminder", "send_notification"]}

Actions support Laravel's dependency injection — constructor parameters are resolved from the container.

Integrating with Other Moffhub Packages

The packages in the Moffhub ecosystem are independent — they don't import each other. Integration happens through your model's action methods and event listeners in your application.

Flow + Billing (moffhub/billing):

class BusinessPermit extends Model implements StatefulModel
{
    use HasWorkflow;

    public function createBill(): void
    {
        // Called by Flow when 'create_bill' action fires
        $this->owner->subscribe('permit-annual-fee')->create();
    }
}

Flow + SMS Handler (moffhub/sms-handler):

class BusinessPermit extends Model implements StatefulModel
{
    use HasWorkflow;

    public function sendWorkflowSms(WorkflowTransition $transition): void
    {
        // Called by Flow when 'send_sms' action fires
        Sms::sendSms(
            $this->applicant_phone,
            "Your permit application has been {$transition->to_state}."
        );
    }
}

Flow + Maker-Checker (moffhub/maker-checker):

Flow handles multi-step business processes. Maker-Checker handles dual-control on data mutations. They serve different purposes but can work together:

// In an event listener:
// When maker-checker approves a rate change, advance the workflow
Event::listen(RequestApproved::class, function (RequestApproved $event) {
    $model = $event->request->subject;
    if ($model instanceof StatefulModel && $model->canTransition('data_verified')) {
        $model->transition('data_verified', 'Rate change approved via maker-checker');
    }
});

Flow events → SMS/Email/Billing via subscribers:

class PermitNotificationSubscriber extends WorkflowSubscriber
{
    public function workflowCodes(): ?array
    {
        return ['business_permit'];
    }

    public function onEnterApproved(WorkflowInstance $instance): void
    {
        $permit = $instance->workflowable;
        Sms::sendSms($permit->applicant_phone, 'Your permit has been approved!');
    }

    public function onApprovalRequired(WorkflowInstance $instance, $transition, array $pendingRoles): void
    {
        foreach ($pendingRoles as $role) {
            // Notify users with this role that their approval is needed
            User::role($role)->each(fn ($user) =>
                Sms::sendSms($user->phone, "Approval needed for permit #{$instance->workflowable_id}")
            );
        }
    }
}

Immutable Audit Trail

Every transition creates a WorkflowHistory record that is never updated or deleted:

$permit->workflowHistory()->each(function ($history) {
    $history->from_state;        // 'submitted'
    $history->to_state;          // 'approved'
    $history->transition_name;   // 'approve'
    $history->performed_by;      // 42 (user ID)
    $history->comment;           // 'All documents verified'
    $history->attribute_changes; // ['amount_paid' => ['old' => 0, 'new' => 1500]]
    $history->approvals;         // [{role: 'ward_officer', status: 'approved', ...}]
    $history->metadata;          // arbitrary context data
    $history->performed_at;      // Carbon datetime
});

Force transitions are logged with transition_name: '__force__' and metadata: {forced: true}.

The history table has no updated_at column — records are write-once.

Attribute Change Tracking

Every transition automatically captures the model's dirty attributes (fields changed but not yet saved) at the moment of transition:

$permit->amount_paid = 1500;
$permit->documents_verified = true;
$permit->transition('approve', 'Payment verified, documents complete');

$history = $permit->workflowHistory()->first();
$history->attribute_changes;
// [
//     'amount_paid'        => ['old' => 0,     'new' => 1500],
//     'documents_verified' => ['old' => false,  'new' => true],
// ]

If the model has no dirty attributes at the time of transition, attribute_changes is null.

This is valuable for compliance auditing — you can see exactly what changed alongside each state transition.

Parallel States (Split/Join)

For workflows where multiple review branches happen concurrently.

Example: A building permit needs legal review AND finance review simultaneously before final approval.

Setup

Set the definition type to workflow (instead of the default state_machine):

{
    "type": "workflow",
    "initial_state": "submitted"
}

Create split and join transitions:

// Split: submitted → [legal_review, finance_review]
{
    "name": "start_reviews",
    "from_state": "submitted",
    "to_state": "legal_review",
    "to_states": ["legal_review", "finance_review"]
}

// Join: [legal_review, finance_review] → approved
{
    "name": "complete_reviews",
    "from_state": "legal_review",
    "to_state": "approved",
    "from_states": ["legal_review", "finance_review"]
}

Usage

$engine = app(WorkflowEngine::class);

// Execute split — model enters both places
$instance = $engine->splitTransition($permit, 'start_reviews');
$instance->getPlaces();    // ['legal_review', 'finance_review']
$instance->isInPlace('legal_review');    // true
$instance->isInPlace('finance_review');  // true

// Complete legal review branch
$engine->joinTransition($permit, 'complete_reviews', 'legal_review');
// → Still waiting for finance_review, instance stays in parallel state

// Complete finance review branch
$instance = $engine->joinTransition($permit, 'complete_reviews', 'finance_review');
// → All branches done! Transition executes → state becomes 'approved'
$instance->current_state;  // 'approved'
$instance->places;         // null (cleared after join)

How it works internally

  • splitTransition() sets places to an array of target states (e.g. ['legal_review', 'finance_review'])
  • joinTransition() removes the completed place from the array
  • When all from_states are completed (removed from places), the join transition fires and moves to the to_state

Scheduled Transitions

Auto-transition at a future time — useful for expiry, auto-publish, or reminder escalation.

Scheduling

// Via model (HasWorkflow trait)
$scheduled = $permit->scheduleTransition(
    'auto_expire',
    now()->addDays(30),
    'Auto-expired after 30 days',
);

// Via engine
$engine = app(WorkflowEngine::class);
$scheduled = $engine->scheduleTransition(
    $permit,
    'submit',
    now()->addHours(24),
    'Auto-submitted',
    ['source' => 'system'],  // context
);

// Cancel a scheduled transition
$engine->cancelScheduledTransition($permit, $scheduled->id);

Processing

Add the job to your application's scheduler:

// bootstrap/app.php or app/Console/Kernel.php
use Moffhub\Flow\Jobs\ProcessScheduledTransitions;

$schedule->job(new ProcessScheduledTransitions)->everyFiveMinutes();

The job finds all scheduled transitions where scheduled_at has passed, is_dispatched is false, and is_cancelled is false, then executes each one through the normal engine (with guards, actions, and events).

Querying scheduled transitions

use Moffhub\Flow\Models\ScheduledTransition;

// All pending (not yet dispatched or cancelled)
ScheduledTransition::pending()->get();

// All due (ready to execute)
ScheduledTransition::due()->get();

// For a specific instance
$permit->workflowInstance->scheduledTransitions()->pending()->get();

Query Scopes

The HasWorkflow trait adds Eloquent query scopes to your model for filtering by workflow state:

// Models in a specific state
BusinessPermit::whereWorkflowState('approved')->get();

// Models NOT in a specific state
BusinessPermit::whereWorkflowNotState('draft')->get();

// Models in any of the given states
BusinessPermit::whereWorkflowStateIn(['submitted', 'under_review'])->get();

// Models whose workflow has completed (terminal state)
BusinessPermit::whereWorkflowComplete()->get();

// Models past their workflow deadline
BusinessPermit::whereWorkflowOverdue()->get();

// Models assigned to a specific user
BusinessPermit::whereWorkflowAssignedTo($userId)->get();

Combine with other scopes:

BusinessPermit::whereWorkflowState('under_review')
    ->whereWorkflowAssignedTo(auth()->id())
    ->where('module', 'liquor')
    ->orderBy('created_at')
    ->paginate(20);

Workflow Subscribers

Convention-based event handling — implement methods named after states and transitions, and the subscriber automatically dispatches to them.

Creating a subscriber

<?php

namespace App\Workflow;

use Moffhub\Flow\Models\WorkflowInstance;
use Moffhub\Flow\Models\WorkflowTransition;
use Moffhub\Flow\Services\WorkflowSubscriber;

class PermitWorkflowSubscriber extends WorkflowSubscriber
{
    /**
     * Only handle specific workflow definition codes.
     * Return null to handle all workflows.
     */
    public function workflowCodes(): ?array
    {
        return ['business_permit', 'liquor_licence'];
    }

    // ─── State Enter Hooks ──────────────────────────────────────
    // Called when the workflow enters a specific state

    public function onEnterSubmitted(WorkflowInstance $instance): void
    {
        // Notify the review team
        ReviewTeam::notify(new PermitSubmittedNotification($instance->workflowable));
    }

    public function onEnterApproved(WorkflowInstance $instance): void
    {
        // Generate certificate
        $instance->workflowable->generateCertificate();
    }

    public function onEnterUnderReview(WorkflowInstance $instance): void
    {
        // Start SLA timer
        $instance->workflowable->setWorkflowDeadline(now()->addDays(14));
    }

    // ─── State Leave Hooks ──────────────────────────────────────
    // Called when the workflow leaves a specific state

    public function onLeaveDraft(WorkflowInstance $instance): void
    {
        Log::info("Permit #{$instance->workflowable_id} left draft state");
    }

    // ─── Transition Hooks ───────────────────────────────────────
    // Called when a specific named transition fires

    public function onTransitionSubmit(WorkflowInstance $instance): void
    {
        // Send receipt to applicant
    }

    public function onTransitionReject(WorkflowInstance $instance): void
    {
        // Notify applicant of rejection
    }

    // ─── Lifecycle Hooks ────────────────────────────────────────

    public function onComplete(WorkflowInstance $instance, string $finalState): void
    {
        // Archive completed workflow
        Log::info("Workflow completed in state: {$finalState}");
    }

    public function onApprovalRequired(
        WorkflowInstance $instance,
        WorkflowTransition $transition,
        array $pendingRoles,
    ): void {
        // Notify users with pending roles
        foreach ($pendingRoles as $role) {
            User::role($role)->each->notify(new ApprovalNeededNotification($instance));
        }
    }
}

Registering subscribers

In config/flow.php:

'subscribers' => [
    App\Workflow\PermitWorkflowSubscriber::class,
    App\Workflow\LicenceWorkflowSubscriber::class,
],

Naming convention

State and transition names are converted to StudlyCase:

Name Method
draft onEnterDraft() / onLeaveDraft()
under_review onEnterUnderReview() / onLeaveUnderReview()
submit onTransitionSubmit()
start_review onTransitionStartReview()

Only implement the methods you need — unimplemented methods are silently skipped.

Events

Flow fires Laravel events at key lifecycle points. Listen to these in your application for cross-cutting concerns:

Event When Properties
WorkflowTransitioned After every successful state change $instance, $transition, $performedBy
WorkflowCompleted When state reaches final or failed type $instance, $finalState
WorkflowApprovalRequired When approval is recorded but gate not yet satisfied $instance, $transition, $requestedBy, $pendingRoles
WorkflowNotificationRequired When send_notification action fires $instance, $transition

Listening in your application:

// In EventServiceProvider or via Event::listen()
use Moffhub\Flow\Events\WorkflowTransitioned;
use Moffhub\Flow\Events\WorkflowCompleted;

Event::listen(WorkflowTransitioned::class, function (WorkflowTransitioned $event) {
    Log::info("Workflow transitioned", [
        'model' => $event->instance->workflowable_type,
        'model_id' => $event->instance->workflowable_id,
        'to_state' => $event->instance->current_state,
        'by' => $event->performedBy,
    ]);
});

Event::listen(WorkflowCompleted::class, function (WorkflowCompleted $event) {
    // Trigger downstream processes
});

Force Transition (Admin Override)

Skip all guards and approval gates — for admin overrides or emergency corrections:

$engine = app(WorkflowEngine::class);

$engine->forceTransition(
    $permit,
    'rejected',          // target state (any state name, even without a defined transition)
    $admin->id,          // performed by
    'Override: compliance violation detected',  // comment
    ['reason' => 'emergency'],  // context
);

Force transitions are logged in history with:

  • transition_name: '__force__'
  • metadata: {forced: true, reason: 'emergency'}

Workflow Visualization

Generate visual diagrams from workflow definitions stored in the database.

Mermaid (default)

php artisan flow:visualize business_permit

Output (paste into GitHub, Notion, or any Mermaid renderer):

stateDiagram-v2
    [*] --> draft
    approved --> [*]
    rejected --> [*]
    draft : Draft
    submitted : Submitted
    under_review : Under Review
    approved : Approved
    rejected : Rejected
    note right of rejected : Failed state

    draft --> submitted : Submit Application
    submitted --> under_review : Start Review [comment]
    under_review --> approved : Approve [approval: 3] [comment]
    under_review --> rejected : Reject [comment]

Graphviz DOT

php artisan flow:visualize business_permit --format=dot --output=permit.dot
dot -Tpng permit.dot -o permit.png

Generates color-coded diagrams:

  • Green nodes for initial/final states
  • Blue nodes for intermediate states
  • Red nodes for failed states
  • Start/end markers (circles)
  • Annotations for approval gates and comment requirements

Write to file

php artisan flow:visualize business_permit --output=docs/workflow.md

Visual Builder API (Block Editor)

Backend API for drag-and-drop workflow editors. States are rectangles with position_x/position_y, transitions are arrows connecting them.

Canvas Save

The main "Save" button. Atomically syncs the entire canvas — creates new elements, updates existing ones, soft-deletes removed ones:

PUT /api/workflows/builder/{definition}/canvas
{
    "initial_state": "draft",
    "states": [
        {
            "id": "01JABC123...",
            "name": "draft",
            "label": "Draft",
            "type": "initial",
            "color": "#94a3b8",
            "position_x": 100,
            "position_y": 50
        },
        {
            "id": "01JDEF456...",
            "name": "submitted",
            "label": "Submitted",
            "type": "intermediate",
            "color": "#3b82f6",
            "position_x": 350,
            "position_y": 50
        },
        {
            "name": "rejected",
            "label": "Rejected",
            "type": "failed",
            "color": "#ef4444",
            "position_x": 350,
            "position_y": 250
        }
    ],
    "transitions": [
        {
            "id": "01JGHI789...",
            "name": "submit",
            "label": "Submit",
            "from_state": "draft",
            "to_state": "submitted"
        },
        {
            "name": "reject",
            "label": "Reject",
            "from_state": "submitted",
            "to_state": "rejected",
            "requires_comment": true,
            "icon": "x-circle",
            "button_color": "#ef4444"
        }
    ]
}

Rules:

  • States/transitions with an id (ULID) are updated
  • States/transitions without an id are created (new rectangle/arrow added)
  • States/transitions not present in the payload are soft-deleted (rectangle/arrow removed)

Bulk Layout Update

After dragging rectangles — only updates positions, nothing else:

PATCH /api/workflows/builder/{definition}/layout
{
    "states": [
        {"id": "01JABC123...", "position_x": 200, "position_y": 100},
        {"id": "01JDEF456...", "position_x": 450, "position_y": 100},
        {"id": "01JGHI789...", "position_x": 450, "position_y": 300}
    ]
}

This is a lightweight call for real-time drag feedback — no validation beyond positions.

Export & Import

Export a definition as a portable JSON canvas (for sharing, backup, or cloning):

GET /api/workflows/builder/{definition}/export

Returns the full definition with all states (including positions) and transitions.

Import a JSON canvas as a new definition:

POST /api/workflows/builder/import
{
    "name": "Cloned Permit Workflow",
    "code": "permit_v2",
    "model_type": "App\\Models\\BusinessPermit",
    "initial_state": "draft",
    "states": [
        {"name": "draft", "label": "Draft", "type": "initial", "position_x": 100, "position_y": 50},
        {"name": "approved", "label": "Approved", "type": "final", "position_x": 500, "position_y": 50}
    ],
    "transitions": [
        {"name": "approve", "label": "Approve", "from_state": "draft", "to_state": "approved"}
    ]
}

Imported definitions are created with is_active: false by default — activate them explicitly after review.

Frontend Integration Pattern

A block editor frontend (React, Vue, etc.) would:

  1. GET /builder/{id}/export to load the canvas
  2. Render states as draggable rectangles at position_x/position_y
  3. Render transitions as arrows between from_state and to_state rectangles
  4. On drag end: PATCH /builder/{id}/layout with new positions
  5. On save: PUT /builder/{id}/canvas with the full state
  6. On clone: GET /builder/{id}/export → modify code → POST /builder/import

State resources include a position object in API responses:

{
    "id": "01JABC123...",
    "name": "draft",
    "label": "Draft",
    "type": "initial",
    "color": "#94a3b8",
    "position": {"x": 100, "y": 50},
    "is_terminal": false
}

REST API Reference

All routes are prefixed with api/workflows (configurable) and use auth:sanctum middleware by default.

Definitions

Method Endpoint Description
GET /definitions List all definitions (with ?include_inactive=true and ?model_type=... filters)
POST /definitions Create a new definition
GET /definitions/{id} Show definition with states and transitions (lookup by ULID, ID, or code)
PATCH /definitions/{id} Update definition
DELETE /definitions/{id} Soft-delete definition (blocked if active instances exist)

States

Method Endpoint Description
GET /definitions/{id}/states List states for a definition
POST /definitions/{id}/states Add a state to a definition
PATCH /states/{id} Update a state
DELETE /states/{id} Soft-delete a state

Transitions

Method Endpoint Description
GET /definitions/{id}/transitions List transitions for a definition
POST /definitions/{id}/transitions Add a transition to a definition
PATCH /transitions/{id} Update a transition
DELETE /transitions/{id} Soft-delete a transition

Instances

Method Endpoint Description
GET /instances List instances (filters: ?state=, ?definition_id=, ?assigned_to=, ?overdue=true, ?per_page=15)
GET /instances/{id} Show instance with history
POST /instances/{id}/transition/{name} Execute a transition (body: {comment, context})
GET /instances/{id}/history Get audit trail
GET /instances/{id}/available-transitions Get structurally valid transitions from current state
GET /instances/{id}/pending-approvals Get pending approval records
POST /instances/{id}/reject-approval/{name} Reject an approval (body: {comment})

Builder (Visual Canvas)

Method Endpoint Description
GET /builder/{id}/export Export full definition as JSON canvas
POST /builder/import Import JSON canvas as new (inactive) definition
PATCH /builder/{id}/layout Bulk update state positions
PUT /builder/{id}/canvas Atomic save of entire canvas

Artisan Commands

flow:seed

Import a workflow definition from a JSON file:

php artisan flow:seed path/to/workflow.json

Creates the definition, states, and transitions. Uses updateOrCreate so running it again updates existing records by code/name.

flow:status

Inspect workflows:

# Overview of all definitions
php artisan flow:status

# Detailed view of a specific workflow
php artisan flow:status business_permit

Shows states, transitions, approval config, and instance distribution by state.

flow:visualize

Generate diagrams:

# Mermaid to stdout
php artisan flow:visualize business_permit

# Graphviz DOT to file
php artisan flow:visualize business_permit --format=dot --output=workflow.dot

Database Schema

7 tables (all names configurable via config/flow.php):

Table Purpose Key Columns
workflow_definitions Blueprint code (unique), model_type, type, initial_state, is_active, soft deletes
workflow_states Nodes name, label, type (enum), color, position_x, position_y, soft deletes
workflow_transitions Edges name, from_state, to_state, from_states/to_states (parallel), guards, actions, approval config, soft deletes
workflow_instances Runtime workflowable (morph), current_state, places (parallel), assigned_to, deadline_at
workflow_history Audit log from_state, to_state, transition_name, performed_by, comment, attribute_changes, approvals, performed_at
workflow_approvals Gate tracking transition_name, required_role, status (pending/approved/rejected), approved_by, acted_at
workflow_scheduled_transitions Future transitions transition_name, scheduled_at, is_dispatched, is_cancelled

Configuration Reference

// config/flow.php
return [

    // Custom action handlers (key → class)
    'actions' => [
        // 'create_inspection_report' => App\Actions\CreateInspectionReport::class,
    ],

    // Custom guard handlers (key → class)
    'guards' => [
        // 'inspection_passed' => App\Guards\InspectionPassedGuard::class,
    ],

    // Workflow subscriber classes
    'subscribers' => [
        // App\Workflow\PermitWorkflowSubscriber::class,
    ],

    // Default approval settings (overridable per transition)
    'approval' => [
        'expiry_hours' => 72,
        'rejection_policy' => 'any', // 'any' or 'majority'
    ],

    // Visualization settings
    'visualization' => [
        'format' => 'mermaid', // 'mermaid' or 'dot'
    ],

    // Route registration
    'routes' => [
        'enabled' => true,
        'prefix' => 'api/workflows',
        'middleware' => ['api', 'auth:sanctum'],
    ],

    // Table names (customize if needed)
    'tables' => [
        'definitions' => 'workflow_definitions',
        'states' => 'workflow_states',
        'transitions' => 'workflow_transitions',
        'instances' => 'workflow_instances',
        'history' => 'workflow_history',
        'approvals' => 'workflow_approvals',
        'scheduled_transitions' => 'workflow_scheduled_transitions',
    ],
];

JSON Seed File Format

For php artisan flow:seed:

{
    "code": "business_permit",
    "name": "Business Permit Workflow",
    "model_type": "App\\Models\\BusinessPermit",
    "module": "permits",
    "type": "state_machine",
    "initial_state": "draft",
    "description": "Standard business permit approval workflow",
    "states": [
        {"name": "draft", "label": "Draft", "type": "initial", "color": "#94a3b8"},
        {"name": "submitted", "label": "Submitted", "type": "intermediate", "color": "#3b82f6"},
        {"name": "under_review", "label": "Under Review", "type": "intermediate", "color": "#f59e0b"},
        {"name": "approved", "label": "Approved", "type": "final", "color": "#22c55e"},
        {"name": "rejected", "label": "Rejected", "type": "failed", "color": "#ef4444"}
    ],
    "transitions": [
        {
            "name": "submit",
            "label": "Submit Application",
            "from_state": "draft",
            "to_state": "submitted"
        },
        {
            "name": "review",
            "label": "Start Review",
            "from_state": "submitted",
            "to_state": "under_review",
            "allowed_roles": ["revenue_officer", "admin"],
            "requires_comment": true,
            "icon": "eye",
            "button_color": "#f59e0b"
        },
        {
            "name": "approve",
            "label": "Approve",
            "from_state": "under_review",
            "to_state": "approved",
            "requires_approval": true,
            "required_approvals": 3,
            "approval_roles": ["ward_officer", "subcounty_officer", "committee_member"],
            "rejection_policy": "any",
            "expiry_hours": 72,
            "escalation_role": "admin",
            "requires_comment": true,
            "conditions": [
                {"field": "amount_paid", "operator": ">=", "value": 1000},
                {"field": "documents_verified", "operator": "==", "value": true}
            ],
            "actions": ["create_bill", "generate_document", "send_notification"],
            "guard_classes": ["inspection_passed"],
            "icon": "check-circle",
            "button_color": "#22c55e"
        },
        {
            "name": "reject",
            "label": "Reject",
            "from_state": "under_review",
            "to_state": "rejected",
            "allowed_roles": ["revenue_officer", "admin"],
            "requires_comment": true,
            "actions": ["send_sms"],
            "icon": "x-circle",
            "button_color": "#ef4444"
        }
    ]
}

Real-World Example: Business Permit

End-to-end example showing all features working together.

Model

class BusinessPermit extends Model implements StatefulModel
{
    use HasWorkflow;

    protected $guarded = ['id'];

    protected function casts(): array
    {
        return [
            'amount_paid' => 'integer',
            'documents_verified' => 'boolean',
        ];
    }

    // ─── Built-in Action Methods ────────────────────────────────

    public function createBill(): void
    {
        // Integration with moffhub/billing
        Invoice::create([
            'permit_id' => $this->id,
            'amount' => $this->calculated_fee,
            'due_at' => now()->addDays(30),
        ]);
    }

    public function generateDocument(string $state): void
    {
        // Generate PDF certificate/permit document
        DocumentGenerator::generate($this, "permit_{$state}");
    }

    public function sendWorkflowSms(WorkflowTransition $transition): void
    {
        // Integration with moffhub/sms-handler
        Sms::sendSms(
            $this->applicant_phone,
            "Permit #{$this->reference}: Status changed to {$transition->label}."
        );
    }

    public function sendWorkflowEmail(WorkflowTransition $transition): void
    {
        Mail::to($this->applicant_email)->send(
            new PermitStatusChanged($this, $transition)
        );
    }
}

Workflow Definition (seed file)

php artisan flow:seed database/seeds/business_permit_workflow.json

Application Controller

class PermitController extends Controller
{
    public function submit(BusinessPermit $permit): JsonResponse
    {
        $permit->transition('submit');

        return response()->json(['message' => 'Application submitted.']);
    }

    public function review(Request $request, BusinessPermit $permit): JsonResponse
    {
        $permit->transition('review', $request->input('comment'));

        return response()->json(['message' => 'Review started.']);
    }

    public function approve(Request $request, BusinessPermit $permit): JsonResponse
    {
        try {
            $permit->amount_paid = $request->input('amount_paid', 0);
            $permit->transition('approve', $request->input('comment'));

            return response()->json(['message' => 'Permit approved.']);
        } catch (ApprovalRequiredException $e) {
            return response()->json([
                'message' => "Approval recorded ({$e->approvedCount}/{$e->requiredCount}).",
                'pending_roles' => $e->pendingRoles,
            ], 202);
        }
    }

    public function dashboard(): JsonResponse
    {
        return response()->json([
            'pending_review' => BusinessPermit::whereWorkflowState('submitted')->count(),
            'under_review' => BusinessPermit::whereWorkflowState('under_review')->count(),
            'overdue' => BusinessPermit::whereWorkflowOverdue()->count(),
            'my_assignments' => BusinessPermit::whereWorkflowAssignedTo(auth()->id())->count(),
            'completed' => BusinessPermit::whereWorkflowComplete()->count(),
        ]);
    }
}

Subscriber

class PermitWorkflowSubscriber extends WorkflowSubscriber
{
    public function workflowCodes(): ?array
    {
        return ['business_permit'];
    }

    public function onEnterSubmitted(WorkflowInstance $instance): void
    {
        // Auto-assign to the ward revenue officer
        $officer = User::role('ward_revenue_officer')
            ->where('ward_id', $instance->workflowable->ward_id)
            ->first();

        if ($officer) {
            $instance->workflowable->assignWorkflowTo($officer->id);
        }

        // Set 14-day SLA deadline
        $instance->workflowable->setWorkflowDeadline(now()->addDays(14));
    }

    public function onEnterApproved(WorkflowInstance $instance): void
    {
        // Schedule auto-renewal check in 11 months
        $instance->workflowable->scheduleTransition(
            'renewal_check',
            now()->addMonths(11),
            'Automatic renewal reminder',
        );
    }

    public function onComplete(WorkflowInstance $instance, string $finalState): void
    {
        Log::info("Permit #{$instance->workflowable_id} workflow completed: {$finalState}");
    }
}

Testing

composer test        # Run PHPUnit tests
composer lint        # Check code style (Laravel Pint)
composer phpstan     # Static analysis (level 6)
composer rector      # Check for code improvements
composer check-code  # Run all of the above

License

MIT