ademakanaky / laravel-workflows
A versioned, extensible workflow and approval engine for Laravel applications.
Requires
- php: ^8.2
- illuminate/console: ^9.52.20|^10.48.28|^11.0|^12.0|^13.0
- illuminate/contracts: ^9.52.20|^10.48.28|^11.0|^12.0|^13.0
- illuminate/database: ^9.52.20|^10.48.28|^11.0|^12.0|^13.0
- illuminate/events: ^9.52.20|^10.48.28|^11.0|^12.0|^13.0
- illuminate/support: ^9.52.20|^10.48.28|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^7.0|^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^9.6|^10.5|^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-06 16:31:49 UTC
README
A versioned, extensible workflow and approval engine for Laravel applications.
Laravel Workflows attaches durable processes to any Eloquent model. It provides explicit states and transitions, immutable definition versions, optional task assignment, transition guards, actor authorization, idempotency, lifecycle events, and an append-only audit history without requiring a particular role or tenancy package.
Requirements
- PHP 8.2 or newer
- Laravel 9.52+, 10.48+, 11, 12, or 13
- A Laravel-supported relational database
Installation
composer require ademakanaky/laravel-workflows php artisan migrate
Laravel discovers the package service provider automatically, and package migrations are loaded automatically. Publish the configuration only when you need to customize behavior:
php artisan vendor:publish --tag=workflows-config
If the application must own and modify its migration, publish it and set load_migrations to false in the published configuration before running migrations. This prevents both copies from being executed:
php artisan vendor:publish --tag=workflows-migrations
Applications upgrading from a release where the original migration was already published with load_migrations disabled can publish only the additive administration migration:
php artisan vendor:publish --tag=workflows-administration-migration php artisan migrate
Define a workflow
Add definitions to config/workflows.php:
'definitions' => [ 'purchase-approval' => [ 'name' => 'Purchase approval', 'states' => [ 'draft' => ['initial' => true], 'manager-review' => [], 'finance-review' => [], 'approved' => ['final' => true], 'rejected' => ['final' => true], ], 'transitions' => [ ['action' => 'submit', 'from' => 'draft', 'to' => 'manager-review'], ['action' => 'approve', 'from' => 'manager-review', 'to' => 'finance-review'], ['action' => 'reject', 'from' => 'manager-review', 'to' => 'rejected'], ['action' => 'approve', 'from' => 'finance-review', 'to' => 'approved'], ['action' => 'reject', 'from' => 'finance-review', 'to' => 'rejected'], ], ], ],
Validate and synchronize definitions:
php artisan workflow:validate php artisan workflow:sync
Synchronization creates a new immutable version only when a definition changes. Existing instances remain pinned to the version on which they started.
Definitions may also be constructed in application code:
use Ademakanaky\LaravelWorkflows\Definitions\WorkflowBlueprint; use Ademakanaky\LaravelWorkflows\Facades\Workflow; $version = Workflow::define( WorkflowBlueprint::make('article-review') ->name('Article review') ->state('draft', initial: true) ->state('review') ->state('published', final: true) ->transition('submit', 'draft', 'review') ->transition('publish', 'review', 'published') );
Attach workflows to a model
use Ademakanaky\LaravelWorkflows\Concerns\HasWorkflows; class PurchaseRequest extends Model { use HasWorkflows; }
The trait exposes workflowInstances() and activeWorkflowInstances() relationships. It does not automatically start workflows when a model is created; explicit startup avoids hidden writes and lets the application supply the correct actor and context.
Start and transition
use Ademakanaky\LaravelWorkflows\Facades\Workflow; $instance = Workflow::start( subject: $purchaseRequest, definition: 'purchase-approval', actor: $request->user(), context: ['amount' => 250_000], idempotencyKey: $request->header('Idempotency-Key'), ); $available = Workflow::availableActions($instance, $request->user()); $instance = Workflow::transition( instance: $instance, action: 'submit', actor: $request->user(), data: ['comment' => 'Ready for review'], idempotencyKey: 'purchase-123-submit', );
Instances also provide a convenience method:
$instance = $instance->transition('approve', actor: $request->user());
All state changes execute inside database transactions and lock the workflow instance row. Supplying idempotency keys makes identical retried starts and transitions return the original result rather than applying the operation twice. Reusing a key with different input throws IdempotencyConflictException.
Subjects and actors may use integer, UUID, or ULID string keys, but they must be persisted Eloquent models.
Cancel a workflow
Cancellation is an audited, idempotent operation intended for trusted application services:
$instance = Workflow::cancel( instance: $instance, actor: $administrator, data: ['reason' => 'The request was withdrawn'], idempotencyKey: 'purchase-123-cancel', );
Cancellation closes every open task, records a cancel history entry, and dispatches WorkflowCancelled after commit. The calling application remains responsible for authorizing cancellation.
Guards
Guards enforce business conditions on a particular transition:
use Ademakanaky\LaravelWorkflows\Contracts\TransitionGuard; class HasSufficientBudget implements TransitionGuard { public function allows($actor, $instance, $transition, array $data): bool { return $instance->subject->remaining_budget >= $instance->context['amount']; } public function message(): string { return 'The remaining budget is insufficient.'; } }
Register a safe alias in config/workflows.php:
'guards' => [ 'sufficient-budget' => App\Workflows\HasSufficientBudget::class, ],
Reference the alias on a transition:
[
'action' => 'approve',
'from' => 'finance-review',
'to' => 'approved',
'guards' => ['sufficient-budget'],
]
Guards are resolved through Laravel's container and must implement TransitionGuard. Class names remain supported for code-managed definitions, while aliases give an administration interface a finite allow-list it can safely display.
Assignment and authorization
The package intentionally has no dependency on Spatie Permission or a particular User model. Implement AssignmentStrategy to choose an assignee whenever a workflow enters a non-final state:
use Ademakanaky\LaravelWorkflows\Contracts\AssignmentStrategy; class LeastBusyApprover implements AssignmentStrategy { public function assign($instance, $state, $actor): ?Model { return User::role($state->metadata['role'] ?? 'approver') ->withCount(['workflowTasks' => fn ($query) => $query->where('status', 'open')]) ->orderBy('workflow_tasks_count') ->first(); } }
Configure it:
'assignment_strategy' => App\Workflows\LeastBusyApprover::class,
That global strategy is the fallback. To let code or an administration interface choose a policy for a particular state, register aliases and reference one in the definition:
'assignment_strategies' => [ 'least-busy-approver' => App\Workflows\LeastBusyApprover::class, ], // Inside a state's array definition: 'manager-review' => [ 'assignment_strategy' => 'least-busy-approver', 'metadata' => ['role' => 'manager'], ],
Guard and assignment-strategy references are verified when a definition is published, so an unavailable extension cannot become a runtime-only failure.
The default TaskTransitionAuthorizer allows unassigned tasks and restricts assigned tasks to their assignee. Replace it with any class implementing TransitionAuthorizer to integrate gates, roles, teams, tenants, or service actors:
'transition_authorizer' => App\Workflows\AuthorizeWorkflowTransition::class,
Applications remain responsible for authorizing access to their HTTP controllers and for preventing untrusted callers from invoking workflow management operations.
Actor models may use the ParticipatesInWorkflows trait to obtain startedWorkflowInstances(), assignedWorkflowTasks(), and workflowActions() relationships.
Trusted application services may manually assign or unassign the current task. The operation is idempotent and recorded in workflow history:
$instance = Workflow::assign( instance: $instance, assignee: $reviewer, actor: $administrator, data: ['reason' => 'Delegated during leave'], idempotencyKey: 'assignment-456', );
Task inbox and notifications
Add ParticipatesInWorkflows to the model that may receive workflow tasks:
use Ademakanaky\LaravelWorkflows\Concerns\ParticipatesInWorkflows; class User extends Authenticatable { use ParticipatesInWorkflows; }
An authenticated user's pending-work page can use the facade-backed inbox. It returns only open tasks assigned to that actor and eager loads the workflow state, definition, and subject:
use Ademakanaky\LaravelWorkflows\Facades\Workflow; $tasks = Workflow::inbox($request->user())->paginate(20); $pendingCount = Workflow::pendingCount($request->user());
The count is suitable for navigation and menu badges. The same API is injectable when a facade is not desired:
use Ademakanaky\LaravelWorkflows\WorkflowInbox; $tasks = app(WorkflowInbox::class)->paginate($request->user(), perPage: 20); $pendingCount = app(WorkflowInbox::class)->count($request->user());
The actor trait also exposes pendingWorkflowTasks(). Task queries may be composed directly:
use Ademakanaky\LaravelWorkflows\Models\WorkflowTask; $tasks = WorkflowTask::query() ->open() ->assignedTo($request->user()) ->get(); $overdue = WorkflowTask::query() ->assignedTo($request->user()) ->overdue() ->get();
Render the permitted actions for each inbox item and submit the selected transition through the normal runtime API:
$available = $task->instance->availableTransitions($request->user()); $instance = $task->instance->transition( action: 'approve', actor: $request->user(), data: ['comment' => $request->string('comment')->toString()], );
For proactive alerts, implement WorkflowTaskNotifier. Its methods run after the enclosing database transaction commits, so notifications are never sent for rolled-back work. The default implementation does nothing.
use Ademakanaky\LaravelWorkflows\Contracts\WorkflowTaskNotifier; use Ademakanaky\LaravelWorkflows\Models\WorkflowTask; use App\Notifications\WorkflowApprovalRequested; use Illuminate\Database\Eloquent\Model; class ApplicationWorkflowNotifier implements WorkflowTaskNotifier { public function opened(WorkflowTask $task, ?Model $actor): void { $recipients = $task->assignee ? collect([$task->assignee]) : $task->candidates->pluck('candidate')->filter(); $recipients->each->notify(new WorkflowApprovalRequested($task)); } public function assigned(WorkflowTask $task, ?Model $previousAssignee, ?Model $actor): void { $task->assignee?->notify(new WorkflowApprovalRequested($task)); } public function completed(WorkflowTask $task, ?Model $actor): void {} public function cancelled(WorkflowTask $task, ?Model $actor): void {} public function nudged(WorkflowTask $task, ?Model $actor, array $data): void { $this->opened($task, $actor); } }
Register it in config/workflows.php:
'task_notifier' => App\Workflows\ApplicationWorkflowNotifier::class,
The notifier can send Laravel database, mail, broadcast, Slack, or other notifications. Queue the application's notification when delivery should happen asynchronously. Applications may instead listen directly for WorkflowTaskOpened, WorkflowTaskAssigned, WorkflowTaskCompleted, and WorkflowTaskCancelled.
Assigned tasks and unassigned tasks for which the actor is a candidate appear in the personal inbox. Configure candidates, an AssignmentStrategy, or explicitly call Workflow::assign() when a state requires an actor to take action.
Administration API
WorkflowAdministration is the supported boundary for an administration interface. Controllers should use this service or the WorkflowAdmin facade instead of updating package tables directly. The host application remains responsible for authorizing administrative routes and actions.
use Ademakanaky\LaravelWorkflows\WorkflowAdministration; $admin = app(WorkflowAdministration::class); $definitions = $admin->definitions()->paginate(); $versions = $admin->versions('purchase-approval')->paginate(); $definition = $admin->definition('purchase-approval');
Configure step participants
Candidates are versioned with the workflow definition. One candidate is assigned automatically. Multiple candidates receive the unassigned task in their inbox and an eligible candidate may claim it.
$blueprint ->candidates('manager-review', [$managerA, $managerB]) ->candidate('finance-review', $financeManager) ->assignmentStrategy('director-review', 'least-busy-director'); $version = $admin->publish($blueprint);
For an existing database-managed definition, this convenience method exports the active version, changes the candidates, and publishes a new immutable version:
$version = $admin->configureStepCandidates( slug: 'purchase-approval', state: 'manager-review', candidates: [$managerA, $managerB], );
Code-managed definitions remain read-only to database administration tools. Change their configuration in code and run workflow:sync.
Candidates are polymorphic Eloquent models. A candidate may therefore be a user, team, role, or another application-owned principal. To make team and role tasks appear in each member's inbox, implement WorkflowParticipantResolver and configure it as participant_resolver. Its principals() method returns the user together with their teams or roles, while matches() determines whether an actor represents a configured principal.
Inspect and search processes
$process = $admin->process($instanceId); $process->currentState; $process->subject; $process->currentTask; $process->currentAssignee; $process->candidateActors; $process->availableTransitions; $process->history; $process->timeInCurrentStateSeconds;
Administration queries support normal Eloquent pagination and task/process scopes:
$processes = $admin->processes() ->running() ->forWorkflow('purchase-approval') ->inState('manager-review') ->assignedTo($manager) ->paginate(); $tasks = $admin->tasks() ->open() ->forWorkflow('purchase-approval') ->inState('manager-review') ->paginate(); $overdue = $admin->tasks()->overdue()->paginate(); $unassigned = $admin->tasks()->open()->unassigned()->paginate();
Claim, release, reassign, and nudge
All responsibility changes and reminders are recorded in the immutable workflow history.
$admin->claim($task, $candidate, idempotencyKey: 'claim-123'); $admin->release($task, $candidate, idempotencyKey: 'release-123'); $admin->reassign($task, $newAssignee, $administrator, ['reason' => 'Covering leave']); $admin->unassign($task, $administrator); $admin->nudge($task, $administrator, ['message' => 'Approval is overdue'], 'nudge-123');
Nudging updates last_nudged_at and nudge_count, creates a nudge history record, dispatches WorkflowTaskNudged after commit, and invokes WorkflowTaskNotifier::nudged().
Activate and deactivate definitions
Publishing a new definition version activates it for new process instances. Existing processes remain pinned to their original version. An administrator may explicitly activate an older version or prevent new starts:
$admin->activate($version, $administrator); $admin->deactivate('purchase-approval', $administrator);
Deactivation does not interrupt already running instances.
Dashboard summaries
$dashboard = $admin->dashboard(); $dashboard->counts; // definitions, processes, open/overdue/unassigned tasks $dashboard->byWorkflow; // open task counts $dashboard->byState; // open task counts $dashboard->byAssignee; // desk workload $dashboard->averageStateSeconds; // completed-task turnaround time
History and events
Every start and transition creates an immutable WorkflowTransitionLog containing the states, action, actor, data, and idempotency key.
$history = $instance->logs() ->with(['fromState', 'toState', 'actor']) ->oldest('created_at') ->get();
The package dispatches:
WorkflowStartinginside the transaction, before an instance is createdWorkflowStartedafter commitWorkflowTransitioninginside the transaction, before state mutationWorkflowTransitionedafter commitWorkflowCompletedafter commit when a final state is enteredWorkflowTaskOpenedafter a new task commitsWorkflowTaskAssignedafter an assignment commitsWorkflowTaskCompletedafter its transition commitsWorkflowTaskCancelledafter cancellation commitsWorkflowTaskClaimedafter a candidate claims a taskWorkflowTaskReleasedafter an assignee releases a taskWorkflowTaskNudgedafter an audited reminder commitsWorkflowDefinitionActivatedafter a version is activatedWorkflowDefinitionDeactivatedafter a definition is deactivatedWorkflowCancelledafter cancellation commitsWorkflowDefinitionPublishingbefore a definition version is persistedWorkflowDefinitionPublishedafter a definition version commits
Listeners for the two pre-mutation events may throw an exception to abort and roll back the operation. Post-commit events are suitable for notifications, webhooks, and queued automation.
Administration-package integration
The runtime package contains supported authoring seams so a separate administration package never needs to edit published records directly.
Definitions have an ownership source:
codedefinitions are produced byworkflow:syncand are read-only to database authoring tools.databasedefinitions are published by an administration interface and cannot be overwritten byworkflow:sync.
An administration package publishes a validated blueprint through the contract:
use Ademakanaky\LaravelWorkflows\Contracts\DefinitionPublisher; use Ademakanaky\LaravelWorkflows\Enums\WorkflowDefinitionSource; $version = app(DefinitionPublisher::class)->publish( $blueprint, WorkflowDefinitionSource::Database, );
Drafts can be checked without writing anything by resolving DefinitionValidator and calling validate($blueprint). This is the same validator used by workflow:validate and DefinitionPublisher, so preview and publication cannot drift onto different rule sets.
WorkflowDefinitionExporter converts any published version back to the same canonical array schema used by WorkflowBlueprint::fromArray(). This supports visual editing, cloning, import/export, diffs, and draft publication without coupling the admin package to internal tables.
WorkflowExtensionRegistry exposes the registered guards and assignment strategies that an interface may safely offer as dropdown choices. State-level assignment policies and transition guards are part of the canonical import/export schema. Mutable drafts and the visual interface belong to the separate admin package; only validated publication crosses into the runtime core.
Custom models
Every package model is replaceable in config/workflows.php. Custom models should extend the corresponding package model so its relationships and casts remain available.
Safety characteristics
- Definitions are validated before persistence.
- Exactly one initial state and at least one final state are required.
- Unknown, duplicate, unreachable, dead-end, non-terminating, and final-state outgoing transitions are rejected.
- Referenced guards and assignment strategies must exist before publication.
- Running instances retain their original definition version.
- Transitions use row locks and database transactions.
- Transition history is append-only through the public API.
- Published definitions, versions, states, transitions, and history records reject destructive Eloquent operations.
- Idempotency protects safely retried commands and rejects conflicting key reuse.
- Authorization and assignment are explicit extension points.
- Code-managed and database-managed definition namespaces cannot overwrite one another.
Testing
composer install
composer test
composer analyse
composer format
The suite uses Orchestra Testbench. CI exercises supported Laravel/PHP combinations with SQLite, runs the feature suite against MySQL and PostgreSQL, audits current dependencies, and installs the package into a clean Laravel application for an end-to-end smoke test.
The supported public API and release guarantees are documented in docs/STABLE_API.md.
Roadmap
The first stable release focuses on deterministic sequential workflows. Planned extensions include parallel approval tasks, quorum approvals, deadlines, escalations, scheduled automation, and optional administration/API packages.
Contributing
See CONTRIBUTING.md.
Security
See SECURITY.md for responsible disclosure guidance.
License
Laravel Workflows is released under the MIT License.