workflow maintained by laravelopenpackages
Workflow
Workflows for Eloquent models: states, guarded moves, tasks with deadlines and a full history — authorised through your own Gate.
A state machine tells you where a record is. A workflow also knows who has to do what before it can move on, what happens when they don't, and who moved it, when. This package is both, in plain Laravel: a definition class, a trait, two tables.
pending ──submit──► in_review ──approve──► approved
│ └──auto-approve (when every review is done)──►
└──────────reject──────────► rejected
- Definitions in code: states (strings or a backed enum), transitions, guards, hooks.
- Moves that explain themselves:
blockers('approve')→['1 task is still open.']. - Tasks with due dates: hand them out on entering a state; finishing the last one can move the subject on its own.
- Overdue and escalation: one scheduled command flags late tasks once and tells whoever should know.
- History: every move, who made it (or the system), with a note.
- Your permissions, not ours: abilities go to Laravel's Gate — define them, use policies, or a permissions package.
- Safe under load: each move runs in a transaction with the row locked; events fire after commit.
- Optional JSON API: "my tasks", complete a task, see a subject's moves, make one.
Laravel 11, 12 and 13 · PHP 8.2+ · works under Octane.
Installation
composer require laravelopenpackages/workflow
php artisan migrate
The package's migration creates workflow_transitions (history) and workflow_tasks.
To change it, publish it and set run_migrations to false:
php artisan vendor:publish --tag=workflow-config # config/workflow.php
php artisan vendor:publish --tag=workflow-migrations
If your models use UUID or ULID keys, set morph_key_type before migrating.
Define a workflow
use LaravelOpenPackages\Workflow\Context;
use LaravelOpenPackages\Workflow\Definition;
use LaravelOpenPackages\Workflow\Flow;
class OrderApprovalFlow extends Definition
{
public function define(Flow $flow): void
{
$flow->column('status') // default: status
->states(OrderStatus::class) // or ->states('pending', 'in_review', …)
->initial(OrderStatus::Pending)
->final(OrderStatus::Approved, OrderStatus::Rejected);
$flow->state(OrderStatus::InReview)->onEnter(function (Order $order, Context $context) {
$context->machine()->assign('review', $order->reviewer, dueAt: now()->addDays(2));
});
$flow->transition('submit')->from(OrderStatus::Pending)->to(OrderStatus::InReview)
->guard(fn (Order $order) => $order->total > 0, "An empty order can't be submitted.");
$flow->transition('approve')->from(OrderStatus::InReview)->to(OrderStatus::Approved)
->can('approve-order')
->requiresTasksCompleted('review');
$flow->transition('reject')->from('*')->to(OrderStatus::Rejected)
->authorize(fn (User $user, Order $order) => $user->is_manager ?: 'Only a manager can reject an order.');
$flow->task('review', 'Review the order')
->escalateTo(fn (WorkflowTask $task) => User::where('is_manager', true)->get());
}
}
from('*') means any state that isn't final. State labels come from the enum's label()
method if it has one, otherwise from the name (in_review → "In Review"); state(…, 'label')
sets one directly. A broken definition (unknown states, leaving a final state…) throws when it
is first built, listing every problem.
Give a model the workflow
use LaravelOpenPackages\Workflow\Concerns\HasWorkflow;
class Order extends Model
{
use HasWorkflow;
protected string $workflowDefinition = OrderApprovalFlow::class;
}
A new order starts in the initial state.
Use it
$order->workflow()->state(); // 'pending'
$order->workflow()->stateLabel(); // 'Pending'
$order->workflow()->is(OrderStatus::Pending); // true
$order->workflow()->can('submit', $user); // true
$order->workflow()->blockers('approve', $user); // ["Can't approve while it is Pending."]
$order->workflow()->available($user); // the moves this user could make, each with its blockers
$order->workflow()->apply('submit', $user, ['note' => 'Rush, please']);
// → TransitionNotAllowed (with ->reasons) if anything blocks it; JSON requests get a 422.
$order->workflow()->history(); // every move, oldest first, with ->actor and ->meta
Logging what isn't a move
Changes that don't move the subject still belong in its story. log() notes them in the
same history as the moves (who, when, anything you pass), so there is one timeline:
$order->workflow()->log('total-changed', $user, ['from' => 300, 'to' => 500]);
$order->workflow()->history(); // moves and logged events, oldest first, with ->actor
The subject stays where it is; the name can't be one of the workflow's transitions.
HistoryLogged is dispatched after the transaction commits. In the JSON API, each history
entry says whether it's a move or a log.
No user = your own code acting (a job, a listener, a command): permissions aren't asked, guards still are. Pass the user whenever a person is behind the move.
Guards
guard(fn ($subject, $user) => …) returns true/null to allow, false to refuse with the
message you gave, or a string as the reason. Guards apply to everyone, the system included —
they're facts about the subject, not about who's asking.
Automatic transitions
->automatic() transitions are made by the engine as soon as nothing blocks them: right after
any move, after a task is completed or cancelled, and whenever you call
$order->workflow()->advance() (do that after changing something a guard reads).
Hooks
before/after on a transition, onEnter/onLeave on a state. They all receive the subject
and a Context (transition, from, to, user, meta, machine()), and run inside the
move's transaction — throw to undo the move.
Strict mode
$flow->strict() refuses any change to the state column that doesn't go through a transition
(StateChangedOutsideWorkflow), so the history can't be bypassed.
Permissions
The package has no permissions of its own, so it never clashes with yours. It asks Laravel's Gate, the one thing every Laravel app already has:
| You write | Who answers |
|---|---|
| nothing | anyone may make the move |
->can('approve-order') |
Gate::define('approve-order', …), a policy method approveOrder(User $user, Order $order), or a Gate::before (e.g. a permissions package) |
->authorize(fn ($user, $order) => …) |
your closure: true allows; false or a reason refuses |
An ability nobody defined is denied, which is Laravel's rule and the safe default: a typo
never opens a door. In debug mode the refusal says the ability isn't defined, and
php artisan workflow:check lists every such ability before you deploy.
To decide some other way entirely, bind your own implementation of
LaravelOpenPackages\Workflow\Contracts\AuthorizesTransitions.
Tasks
$task = $order->workflow()->assign('review', $reviewer, dueAt: now()->addDays(2), data: ['priority' => 'high']);
$order->workflow()->openTasks(); // open tasks of the current state
$task->complete($user, ['verdict' => 'ok']);
$task->reassign($someoneElse);
$task->cancel('No longer needed');
WorkflowTask::query()->open()->assignedTo($user)->get();
A task belongs to the state it was handed out in. When the subject leaves that state, its open
tasks there are cancelled ($flow->keepTasksOnLeave() to keep them). requiresTasksCompleted()
blocks a move while tasks are open — all of them, or only the names you give.
Overdue tasks
// routes/console.php
Schedule::command('workflow:overdue')->hourly();
Each past-due task is flagged once: its onOverdue hooks run, escalateTo recipients are
notified, the assignee is notified (if notifications are on) and TaskOverdue fires.
Notifications
Off by default. With notifications.enabled, assignees (any model using Notifiable) get
TaskAssignedNotification and TaskOverdueNotification on the channels you configure
(database by default). They're sent after the transaction commits.
Events
All are dispatched after the database transaction commits:
TransitionApplied, HistoryLogged, TaskAssigned, TaskCompleted, TaskCancelled, TaskReassigned and TaskOverdue.
HTTP API (optional)
Turn it on in config/workflow.php (http.enabled), and list the models it may serve by a
public name. It never takes a class name from a request:
'subjects' => ['order' => App\Models\Order::class],
GET workflow/tasks |
the signed-in user's open tasks, soonest due first |
POST workflow/tasks/{task}/complete |
complete one (data optional) — the assignee only, unless complete_ability says otherwise |
GET workflow/subjects/order/{id} |
state, the moves this user could make (with blockers), tasks, history |
POST workflow/subjects/order/{id}/transitions/{transition} |
make a move (note optional); 422 with reasons if refused |
Subjects are shown and moved only when the user passes http.view_ability (view by default)
on the subject: give your model a policy with view(), or set it to null. Middleware,
prefix and route names are configurable.
Commands
php artisan workflow:check # build every workflow, warn about unreachable states and undefined abilities
php artisan workflow:graph order # Mermaid state diagram (paste into GitHub, Notion, mermaid.live…)
php artisan workflow:overdue # flag overdue tasks (schedule it)
Testing
composer test
License
MIT