maestrodimateo/laravel-workflow 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

maestrodimateo/laravel-workflow

Composer 安装命令:

composer require maestrodimateo/laravel-workflow

包简介

Workflow engine for Laravel applications

README 文档

README

Latest Version on Packagist License

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 & helperWorkflow::for($model)->transition($basketId) or workflow($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 QueueableAction already runs after commit — you don't need to also implement AfterCommitAction.

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 .json file
  • Export PNG — download a high-resolution image of the workflow diagram
  • Import — select a .json file 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.

Credits

maestrodimateo/laravel-workflow 适用场景与选型建议

maestrodimateo/laravel-workflow 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 79 次下载、GitHub Stars 达 2, 最近一次更新时间为 2026 年 04 月 06 日, 在 PHP 生态内属于活跃度较高的组件。

我们在过去多个企业项目中使用过 maestrodimateo/laravel-workflow 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。

围绕 maestrodimateo/laravel-workflow 我们能提供哪些服务?
定制开发 / 二次开发

基于 maestrodimateo/laravel-workflow 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。

BUG 修复 & 性能优化

线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。

项目外包 & 长期维护

承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。

yvsm@zunyunkeji.com QQ:316430983 微信:yvsm316 西安尊云信息科技 · 专注 PHP / Go / 分布式系统研发

统计信息

  • 总下载量: 79
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 2
  • 点击次数: 14
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 2
  • Watchers: 0
  • Forks: 1
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-04-06