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.
- 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
- 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.
- PHP 8.4+
- Laravel 12.x or 13.x
composer require moffhub/flowPublish and run migrations:
php artisan vendor:publish --tag=flow-migrations
php artisan migrateOptionally publish the config:
php artisan vendor:publish --tag=flow-configuse 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.
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.jsonOption C: Via visual builder (see Visual Builder API)
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"
}$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(); // falseWorkflowDefinition ← 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.
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 │
└──────────────────────────────────────────────┘
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 |
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).
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');// Global helper — returns the WorkflowEngine instance
$engine = flow();
$engine->transition($model, 'approve', 'Looks good');Guards run before a transition is allowed. If any guard fails, the transition is blocked with a TransitionDeniedException (HTTP 403).
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).
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.
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 |
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.
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');| 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"}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 run after a transition succeeds. They are side-effects that should only happen on confirmed state changes.
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.
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.
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}")
);
}
}
}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.
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.
For workflows where multiple review branches happen concurrently.
Example: A building permit needs legal review AND finance review simultaneously before final approval.
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"]
}$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)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
Auto-transition at a future time — useful for expiry, auto-publish, or reminder escalation.
// 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);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).
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();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);Convention-based event handling — implement methods named after states and transitions, and the subscriber automatically dispatches to them.
<?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));
}
}
}In config/flow.php:
'subscribers' => [
App\Workflow\PermitWorkflowSubscriber::class,
App\Workflow\LicenceWorkflowSubscriber::class,
],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.
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
});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'}
Generate visual diagrams from workflow definitions stored in the database.
php artisan flow:visualize business_permitOutput (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]
php artisan flow:visualize business_permit --format=dot --output=permit.dot
dot -Tpng permit.dot -o permit.pngGenerates 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
php artisan flow:visualize business_permit --output=docs/workflow.mdBackend API for drag-and-drop workflow editors. States are rectangles with position_x/position_y, transitions are arrows connecting them.
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)
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 a definition as a portable JSON canvas (for sharing, backup, or cloning):
GET /api/workflows/builder/{definition}/exportReturns 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.
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
}All routes are prefixed with api/workflows (configurable) and use auth:sanctum middleware by default.
| 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) |
| 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 |
| 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 |
| 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}) |
| 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 |
Import a workflow definition from a JSON file:
php artisan flow:seed path/to/workflow.jsonCreates the definition, states, and transitions. Uses updateOrCreate so running it again updates existing records by code/name.
Inspect workflows:
# Overview of all definitions
php artisan flow:status
# Detailed view of a specific workflow
php artisan flow:status business_permitShows states, transitions, approval config, and instance distribution by state.
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.dot7 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 |
// 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',
],
];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"
}
]
}End-to-end example showing all features working together.
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)
);
}
}php artisan flow:seed database/seeds/business_permit_workflow.jsonclass 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(),
]);
}
}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}");
}
}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 aboveMIT