maestrodimateo / laravel-workflow
Workflow engine for Laravel applications
Package info
github.com/maestrodimateo/laravel-workflow
pkg:composer/maestrodimateo/laravel-workflow
Requires
- php: ^8.3 || ^8.4
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
README
A visual, configurable workflow engine for Laravel. Define circuits (workflow definitions), baskets (steps), and transitions — then move any Eloquent model through them with a clean Facade API.
Comes with a built-in visual admin interface to design your workflows by drag-and-drop.
Features
- Visual workflow designer — drag-and-drop baskets, draw transitions, configure actions
- Facade & helper —
Workflow::for($model)->transition($basketId)orworkflow($model)->transition($basketId) - Multi-circuit — a model can belong to multiple workflows, scoped with
in($circuit) - Resource locking — prevent concurrent access with
lock()/unlock() - Role-based access — define allowed roles per circuit and per basket
- Transition actions — attach actions (email, webhook, log, require documents, custom) to transitions
- Duration tracking — automatic timing between steps with human-readable formatting
- Full history — every transition is logged with who, when, how long, and why
- Message templates — WYSIWYG editor with variable interpolation
- Export / Import — share workflows as JSON files + PNG image export
- Dark mode — the admin UI supports light and dark themes
- Zero build step — the admin UI uses Alpine.js + Tailwind CDN, no npm required
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require maestrodimateo/laravel-workflow
Publish the config and migrations:
php artisan vendor:publish --tag=workflow-config php artisan vendor:publish --tag=workflow-migrations php artisan migrate
Optionally publish the views to customize the admin UI:
php artisan vendor:publish --tag=workflow-views
Quick Start
1. Add the trait to your model
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; use Maestrodimateo\Workflow\Traits\Workflowable; class Invoice extends Model { use HasUuids, Workflowable; }
When an Invoice is created, it's automatically placed in the DRAFT basket of the circuit targeting it.
2. Open the visual designer
Navigate to /workflow/admin in your browser. From there you can:
- Create a circuit (workflow) targeting your model
- Add baskets (steps) with colors and roles
- Draw transitions (links) between baskets by dragging from one to another
- Configure actions on each transition (send email, call webhook, etc.)
3. Transition models in your code
use Maestrodimateo\Workflow\Facades\Workflow; // Get current status $basket = Workflow::for($invoice)->currentStatus(); echo $basket->name; // "Brouillon" echo $basket->status; // "DRAFT" // See available next steps $options = Workflow::for($invoice)->nextBaskets(); // Transition Workflow::for($invoice)->transition( $nextBasket->id, 'Approved by manager', // optional comment );
The workflow() helper is also available:
workflow($invoice)->currentStatus(); workflow($invoice)->transition($basketId);
Facade API Reference
All methods are available via Workflow:: or workflow()->.
Model-bound methods
These methods require Workflow::for($model) first:
$wf = Workflow::for($model);
| Method | Returns | Description |
|---|---|---|
in($circuit) |
WorkflowManager |
Scope to a specific circuit (required for multi-circuit) |
currentStatus() |
?Basket |
Current basket (step) of the model |
nextBaskets() |
Collection |
Available baskets to transition to |
transition($id, $comment) |
bool |
Move the model to the next basket |
history() |
Collection |
Full transition history with durations |
totalDuration() |
int |
Total processing time in seconds |
durationInStatus($status) |
int |
Time spent in a specific status (seconds) |
allStatuses() |
array |
Current basket per circuit |
circuits() |
Collection |
All circuits the model belongs to |
lock($minutes) |
WorkflowLock |
Lock the model for exclusive access |
unlock($force) |
void |
Release the lock |
isLocked() |
bool |
Check if locked |
isLockedByMe() |
bool |
Check if locked by the current user |
lockedBy() |
?string |
User ID holding the lock |
lockExpiration() |
?Carbon |
When the lock expires |
requiredDocuments($basketId) |
array |
Documents required for a transition |
requirements() |
array |
All requirements for all next transitions |
Static methods
These methods don't require for():
| Method | Returns | Description |
|---|---|---|
importFromJson($path) |
Circuit |
Import a circuit from an exported JSON file (for seeders/commands) |
registerAction($class) |
void |
Register a custom transition action |
getRegisteredActions() |
array |
List all registered action classes |
Role-based queries
These methods don't require for():
// Circuits accessible to a role Workflow::circuitsForRole('manager'); Workflow::circuitsForRoles(['admin', 'manager']); // Baskets accessible to a role (optionally scoped to a circuit) Workflow::basketsForRole('validator'); Workflow::basketsForRole('validator', $circuitId); Workflow::basketsForRoles(['admin', 'operator'], $circuitId);
Eloquent scopes are also available directly:
use Maestrodimateo\Workflow\Models\Circuit; use Maestrodimateo\Workflow\Models\Basket; Circuit::forRole('admin')->get(); Basket::forRoles(['admin', 'manager'])->get(); $basket->hasRole('validator'); // true/false $circuit->hasRole('admin'); // true/false
Duration Tracking
Every transition automatically records the time spent in the previous step:
$history = Workflow::for($invoice)->history(); foreach ($history as $entry) { echo $entry->previous_status; // "DRAFT" echo $entry->next_status; // "REVIEW" echo $entry->duration_seconds; // 3600 echo $entry->duration_human; // "1h" echo $entry->done_by; // User ID echo $entry->comment; // "Sent for review" } // Total time $seconds = Workflow::for($invoice)->totalDuration(); // Time in a specific step $reviewTime = Workflow::for($invoice)->durationInStatus('REVIEW');
Human-readable formats: 45s, 12min, 2h 35min, 3j 4h.
Multi-Circuit Support
A model can belong to multiple circuits simultaneously. Use in() to scope operations:
// Scope to a specific circuit Workflow::for($invoice)->in($approvalCircuit)->currentStatus(); Workflow::for($invoice)->in($complianceCircuit)->transition($basketId); Workflow::for($invoice)->in('circuit-uuid')->history(); // See status in ALL circuits at once $statuses = Workflow::for($invoice)->allStatuses(); // [ // 'circuit-a-id' => ['circuit' => Circuit, 'basket' => Basket], // 'circuit-b-id' => ['circuit' => Circuit, 'basket' => Basket], // ] // List circuits the model belongs to $circuits = Workflow::for($invoice)->circuits();
When a model is created, it's automatically attached to the DRAFT basket of every circuit targeting its class.
Resource Locking
Prevent multiple operators from working on the same model simultaneously:
// Lock the model (default: 30 minutes) Workflow::for($invoice)->lock(); Workflow::for($invoice)->lock(60); // 1 hour // Check lock status Workflow::for($invoice)->isLocked(); // true Workflow::for($invoice)->isLockedByMe(); // true Workflow::for($invoice)->lockedBy(); // "user-uuid" Workflow::for($invoice)->lockExpiration(); // Carbon instance // Transition (auto-checks the lock) Workflow::for($invoice)->transition($basketId); // → OK if you hold the lock (lock is released after transition) // → ModelLockedException if locked by someone else // Release manually Workflow::for($invoice)->unlock(); // Admin force unlock Workflow::for($invoice)->unlock(force: true);
Query scopes
// Available models (not locked or lock expired) Invoice::fromBasket($reviewBasket)->unlocked()->get(); // Models I'm working on Invoice::lockedBy(auth()->id())->get();
Handling lock exceptions
use Maestrodimateo\Workflow\Exceptions\ModelLockedException; try { Workflow::for($invoice)->transition($basketId); } catch (ModelLockedException $e) { return back()->withErrors([ 'lock' => $e->getMessage(), // "Ce dossier est verrouillé par [user] jusqu'à [14:30]." ]); }
Configure the default lock duration in .env:
WORKFLOW_LOCK_DURATION=30 # minutes
Transition Actions
Actions are executed automatically when a specific transition occurs. They are configured visually in the admin UI.
Built-in actions
| Action | Key | Config | Runs |
|---|---|---|---|
| Send email | send_email |
Select a message from the circuit | Queue (after commit) |
| Webhook | webhook |
URL to POST to | Queue (after commit) |
| Log | log |
Optional message | After commit |
| Require documents | require_document |
List of documents (type + label) | In transaction |
Transaction behavior
Every transition() is wrapped in a single DB transaction (model move, history insert, lock release). Each action runs in one of three modes depending on which marker interface it implements:
- In transaction (default) — the action runs inside the transition transaction. A thrown exception rolls back the entire transition. Use this for validations (e.g.,
require_document) or DB writes that must be atomic with the transition. - After commit (
AfterCommitAction) — the action runs inline once the transition has been committed to the database. Use this for fast non-rollbackable side effects (logging, in-memory cache invalidation) where queueing would be overkill. - Queued (
QueueableAction) — the action is wrapped in a job and dispatched after commit, executed by a queue worker. Use this for slow or external side effects (HTTP calls, email, third-party APIs) so the request returns immediately and transient failures can be retried by the worker.
SendEmailAction and WebhookAction implement QueueableAction. LogTransitionAction implements AfterCommitAction. RequireDocumentAction runs inline — its throw must be able to abort the transition.
Custom actions
Generate a new action with artisan:
php artisan make:workflow-action GeneratePdfAction
This creates app/Workflow/Actions/GeneratePdfAction.php:
use Maestrodimateo\Workflow\Contracts\TransitionAction; class GeneratePdfAction implements TransitionAction { public static function key(): string { return 'generate_pdf'; } public static function label(): string { return 'Generate Pdf'; } public function execute(Model $model, Basket $from, Basket $to, array $config = []): void { // Your logic here } }
Register it in your AppServiceProvider::boot():
Workflow::registerAction(GeneratePdfAction::class);
The action immediately appears in the admin UI's "Add action" menu on any transition.
Opting an action out of the transaction
If your custom action produces an external side effect that cannot be rolled back (HTTP call, email, push notification, third-party SDK), add the AfterCommitAction marker so it only runs once the transition is safely committed:
use Maestrodimateo\Workflow\Contracts\AfterCommitAction; use Maestrodimateo\Workflow\Contracts\TransitionAction; class NotifySlackAction implements TransitionAction, AfterCommitAction { public static function key(): string { return 'notify_slack'; } public static function label(): string { return 'Notify Slack'; } public function execute(Model $model, Basket $from, Basket $to, array $config = []): void { Http::post($config['webhook_url'], [ 'model' => $model->getKey(), 'to' => $to->status, ]); } }
Validation-style actions (those that may throw to abort the transition) should NOT implement AfterCommitAction — their exception needs to roll back the transition, which is only possible from inside the transaction.
Running an action on a queue
For slow side effects (HTTP calls, email, third-party APIs), implement QueueableAction instead. The action is wrapped in a job and dispatched after commit, so the request returns immediately and the worker picks it up:
use Maestrodimateo\Workflow\Contracts\QueueableAction; use Maestrodimateo\Workflow\Contracts\TransitionAction; class NotifySlackAction implements TransitionAction, QueueableAction { public static function key(): string { return 'notify_slack'; } public static function label(): string { return 'Notify Slack'; } // Return null to use the package default (workflow.actions_queue.queue), // then Laravel's default queue. public static function queue(): ?string { return 'notifications'; } // Same fallback chain for the queue connection. public static function connection(): ?string { return null; } public function execute(Model $model, Basket $from, Basket $to, array $config = []): void { Http::post($config['webhook_url'], [ 'model' => $model->getKey(), 'to' => $to->status, ]); } }
What you get for free:
- Race-free dispatch — the job is sent inside
DB::afterCommit(), so the worker can never read the transition's rows before they're visible. - Fresh state on the worker — the subject, source and target baskets are serialized by reference (
SerializesModels) and re-fetched from the DB when the job runs. - Automatic retries — Laravel's queue worker handles retries, backoff, and failed-jobs storage like any other job.
Override the queue and connection globally via .env:
WORKFLOW_ACTIONS_QUEUE=workflow WORKFLOW_ACTIONS_QUEUE_CONNECTION=redis
With QUEUE_CONNECTION=sync (the Laravel default), queueable actions run inline on the request — useful for local development without a running worker. Switching to redis, database, or sqs is enough to move them off the request lifecycle; no code change required.
A
QueueableActionalready runs after commit — you don't need to also implementAfterCommitAction.
Events
A TransitionEvent is fired after every transition. Add your own listeners:
// In your EventServiceProvider protected $listen = [ \Maestrodimateo\Workflow\Events\TransitionEvent::class => [ \App\Listeners\NotifySlack::class, \App\Listeners\SyncWithExternalSystem::class, ], ];
public function handle(TransitionEvent $event): void { $event->currentBasket; // Source basket $event->nextBasket; // Target basket $event->model; // The transitioned model $event->comment; // Transition comment }
What happens during a transition
1. Lock guard — throws ModelLockedException if locked by another user
2. DB transaction opens
a. Model detached from current basket, attached to next
b. In-transaction actions executed (e.g. require_document — may throw and rollback)
c. TransitionEvent fired → HistoryListener records history with duration
d. Lock released
3. DB transaction commits
4. After-commit actions executed inline (log, any AfterCommitAction)
5. Queueable actions dispatched to the queue (send_email, webhook, any QueueableAction)
6. Your custom listeners run
Message Templates
Messages are created at the circuit level and can be used in transition actions (e.g., send_email).
The WYSIWYG editor supports variable interpolation:
Bonjour, la demande {{ reference }} a été transférée de {{ from_name }}
vers {{ to_name }} par {{ user }} le {{ datetime }}.
Built-in variables
| Variable | Description |
|---|---|
{{ model_id }} |
Model identifier |
{{ model_type }} |
Model class name |
{{ from_status }} / {{ from_name }} |
Source basket |
{{ to_status }} / {{ to_name }} |
Target basket |
{{ circuit_name }} |
Circuit name |
{{ date }} / {{ heure }} / {{ datetime }} |
Current date/time |
{{ user }} |
User performing the transition |
Custom variables
// config/workflow.php 'message_variables' => [ 'reference' => fn ($model) => $model->reference, 'montant' => fn ($model) => number_format($model->amount, 2, ',', ' ') . ' €', ],
Export / Import
In the admin UI
- Export JSON — download the full circuit definition as a
.jsonfile - Export PNG — download a high-resolution image of the workflow diagram
- Import — select a
.jsonfile to recreate a circuit with all its configuration
Via API
GET /workflow/admin/api/circuits/{circuit}/export
POST /workflow/admin/api/circuits/import # multipart form, field: "file"
Programmatic import (seeders, commands)
Import a previously exported JSON file directly from code — no HTTP request needed:
use Maestrodimateo\Workflow\Facades\Workflow; // In a seeder Workflow::importFromJson(database_path('seeders/workflow-invoices.json')); // Or via the manager directly app(\Maestrodimateo\Workflow\WorkflowManager::class)::importFromJson($path);
The method creates the full circuit (baskets, transitions, messages) inside a database transaction and returns the newly created Circuit instance with all relations loaded.
Throws \InvalidArgumentException if the file is missing or has an invalid format.
Configuration
// config/workflow.php return [ 'routes' => [ 'prefix' => 'workflow', 'middleware' => ['api'], 'admin_middleware' => ['web'], ], 'auth_identifier' => 'id', 'message_variables' => [], 'actions' => [], 'actions_queue' => [ 'queue' => env('WORKFLOW_ACTIONS_QUEUE'), // null → Laravel's default queue 'connection' => env('WORKFLOW_ACTIONS_QUEUE_CONNECTION'), // null → Laravel's default connection ], 'lock' => [ 'duration_minutes' => 30, ], ];
Workflowable Trait
The Workflowable trait adds relations, methods, and scopes directly on your model instance. Everything below is available without using the Facade.
Relations
$invoice->baskets; // Collection<Basket> — all baskets the model is/has been in $invoice->histories; // Collection<History> — all transition history entries $invoice->workflowLock; // WorkflowLock|null — the active lock on this model
Methods
// Current status (last basket attached) $invoice->currentStatus(); // ?Basket — across all circuits $invoice->currentStatus($circuit); // ?Basket — in a specific circuit $invoice->currentStatus('circuit-uuid'); // same with a string ID // Inspect the current basket $invoice->currentStatus()->status; // "REVIEW" $invoice->currentStatus()->name; // "Under Review" $invoice->currentStatus()->color; // "#2563eb" $invoice->currentStatus()->roles; // ["manager", "validator"] $invoice->currentStatus()->hasRole('manager'); // true // Access the circuit $invoice->currentStatus()->circuit->name; // "Invoice Approval" // Navigate the workflow graph $invoice->currentStatus()->next; // Collection<Basket> — possible next steps $invoice->currentStatus()->previous; // Collection<Basket> — where it came from // History with duration tracking $invoice->histories->each(function ($h) { $h->previous_status; // "DRAFT" $h->next_status; // "REVIEW" $h->comment; // "Sent for review" $h->done_by; // "user-uuid" $h->duration_seconds; // 3600 $h->duration_human; // "1h" $h->created_at; // Carbon }); // Lock info $invoice->workflowLock?->locked_by; // "user-uuid" or null $invoice->workflowLock?->expires_at; // Carbon or null $invoice->workflowLock?->isActive(); // true if not expired
Scopes
// Models currently in a specific basket Invoice::fromBasket($reviewBasket)->get(); // Available models (not locked or lock expired) Invoice::unlocked()->get(); // Models locked by a specific user Invoice::lockedBy(auth()->id())->get(); // Combine scopes Invoice::fromBasket($reviewBasket)->unlocked()->get();
Automatic Behavior
When a model is created, it's automatically attached to the DRAFT basket of every circuit targeting its class:
$invoice = Invoice::create(['number' => 'INV-001']); $invoice->currentStatus()->status; // "DRAFT" — automatic $invoice->baskets->count(); // 1 (or more if multiple circuits)
Admin Interface
The visual designer at /workflow/admin provides:
- Circuit management — create, edit, delete circuits with role assignment
- Drag-and-drop canvas — position baskets freely, auto-layout button
- Visual linking — click output port, then click target basket to create transitions
- Transition config — click a link to add label and actions
- Message editor — WYSIWYG editor with variable interpolation
- Export / Import — JSON + PNG export, JSON import
- Zoom — scroll wheel + controls
- Dark mode — toggle between light and dark themes
- No build step — works out of the box, powered by Alpine.js + Tailwind CDN
Testing
composer test
Or with Pest directly:
./vendor/bin/pest
License
MIT. See LICENSE for details.