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.
Requires
- php: ^8.4|^8.5
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^10.6|^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5|^12.5
- rector/rector: ^2.0
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
- Requirements
- Installation
- Quick Start
- Core Concepts
- Transition Lifecycle
- Model Integration
- Fluent API (Flow Facade)
- Guards
- Approval Gates
- Actions
- Immutable Audit Trail
- Attribute Change Tracking
- Parallel States (Split/Join)
- Scheduled Transitions
- Query Scopes
- Workflow Subscribers
- Events
- Force Transition (Admin Override)
- Workflow Visualization
- Visual Builder API (Block Editor)
- REST API Reference
- Artisan Commands
- Database Schema
- Configuration Reference
- JSON Seed File Format
- Real-World Example: Business Permit
- Testing
- License
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 (
anyormajority), 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 facade —
Flow::for($model)->currentState(),->transitionTo(),->history(). - Artisan commands —
flow: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:
- Every state change is a transition — no direct state mutation.
- Every transition is logged — immutable audit trail.
- Guards run before — role, permission, condition checks block invalid transitions.
- Actions run after — side-effects only happen on successful state changes.
- 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:
- Looks up the active
WorkflowDefinitionmatching the model's morph class - Creates a
WorkflowInstancein the definition'sinitial_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:
- User with
ward_revenue_officerrole calls$permit->transition('approve')→ approval recorded (1/3),ApprovalRequiredExceptionthrown - User with
subcounty_revenue_officerrole calls$permit->transition('approve')→ approval recorded (2/3), exception thrown - User with
liquor_committee_memberrole 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()setsplacesto an array of target states (e.g.['legal_review', 'finance_review'])joinTransition()removes the completed place from the array- When all
from_statesare completed (removed from places), the join transition fires and moves to theto_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
idare 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:
GET /builder/{id}/exportto load the canvas- Render states as draggable rectangles at
position_x/position_y - Render transitions as arrows between
from_stateandto_staterectangles - On drag end:
PATCH /builder/{id}/layoutwith new positions - On save:
PUT /builder/{id}/canvaswith the full state - 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