Looking to hire Laravel developers? Try LaraJobs

workflow maintained by laravelopenpackages

Description
Workflows for Eloquent models: states, guarded moves, tasks with deadlines and a full history — authorised through your own Gate.
Author
Zeeshan Ali
Last update
2026/11/04 04:01 (dev-main)
License
Downloads
12

Comments
comments powered by Disqus

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