qnox / workflows
Configurable, GUI-driven workflow engine for Laravel (dynamic action keywords)
Requires
- php: >=8.3
- illuminate/auth: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/notifications: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
README
Laravel workflow package for configurable approval flows with ordered levels, per-level track history, assignee resolution, and setup-driven actions.
Install
composer require qnox/workflows
php artisan vendor:publish --tag=qnox-workflows-config
php artisan vendor:publish --tag=qnox-workflows-migrations
php artisan vendor:publish --tag=qnox-workflows-views # only when overriding UI
php artisan migrate
After installation, the default settings dashboard is:
/settings/workflows
The prefix, middleware, route names, API routes, and every package view are configurable
in config/workflows.php.
For production, add your authorization middleware to the settings route group:
'routes' => [ 'web' => [ 'middleware' => ['web', 'auth', 'can:workflows.manage'], ], ],
Define the workflows.manage ability with a Laravel gate or create the equivalent
permission when using spatie/laravel-permission.
Concepts
Workflow: the whole process definitionWorkflowLevel: a step inside the processWorkflowAssignment: who can act on a levelWorkflowTransition: an allowed action from one level to anotherWorkflowInstance: one running record for a subjectWorkflowInstanceLevel: track/history rows for the running instanceWorkflowAction: the actions actually taken
Subject Model
The workflow instance belongs to a polymorphic subject. The subject is the business resource being reviewed or approved.
Examples of a subject:
LeaveRequestPurchaseRequisitionInvoiceTender
The user is not the subject unless the thing being approved is actually a user record.
In this package:
subject= the resource under workflowinitiator= the user who started the workflowactor= the user who performs an action on a level
The package stores the subject through:
workflow_instances.subject_typeworkflow_instances.subject_id
Example:
$instance = app(WorkflowEngine::class)->start( $purchaseRequisition, // subject $workflow, auth()->user(), // initiator ['amount' => $purchaseRequisition->amount] );
Basic Usage
use Qnox\Workflows\Services\WorkflowEngine; use Qnox\Workflows\Models\Workflow; $engine = app(WorkflowEngine::class); $workflow = Workflow::where('slug', 'application-approval')->firstOrFail(); $instance = $engine->start($application, $workflow, auth()->user(), [ 'amount' => 5000000, 'country' => 'TZ', ]); $actions = $engine->availableActions($instance, auth()->user()); $engine->act($instance, 'submit', auth()->user(), ['comment' => 'Submitted to supervisor']);
Setup a Workflow
Create a workflow, then define levels, assignments, and transitions.
use Qnox\Workflows\Models\Workflow; use Qnox\Workflows\Models\WorkflowLevel; use Qnox\Workflows\Models\WorkflowAssignment; use Qnox\Workflows\Models\WorkflowTransition; $workflow = Workflow::create([ 'workflow_group_id' => 1, 'name' => 'Application Approval', 'slug' => 'application-approval', 'is_active' => true, ]); $applicantLevel = WorkflowLevel::create([ 'workflow_id' => $workflow->id, 'name' => 'Applicant', 'sequence' => 1, 'is_start' => true, 'description' => 'Application owner prepares and submits', ]); $supervisorLevel = WorkflowLevel::create([ 'workflow_id' => $workflow->id, 'name' => 'Supervisor Review', 'sequence' => 2, 'description' => 'Supervisor reviews the application', 'is_approval' => true, ]); $financeLevel = WorkflowLevel::create([ 'workflow_id' => $workflow->id, 'name' => 'Finance Approval', 'sequence' => 3, 'description' => 'Finance approves and closes', 'is_terminal' => true, 'can_close' => true, ]);
Setup Assignments
Assignments decide who is allowed to act on a level.
Applicant-owned first level:
WorkflowAssignment::create([ 'workflow_level_id' => $applicantLevel->id, 'criteria' => ['initiator' => true], ]);
Direct user assignment:
WorkflowAssignment::create([ 'workflow_level_id' => $supervisorLevel->id, 'assignable_type' => App\Models\User::class, 'assignable_id' => 12, ]);
Criteria-based assignment:
WorkflowAssignment::create([ 'workflow_level_id' => $financeLevel->id, 'criteria' => [ 'department_id' => 3, 'permissions' => ['payments.approve'], ], ]);
Setup Transitions
Actions are configured in workflow_transitions. The engine does not generate fallback actions.
- Each transition defines
action_key,label,direction,to_level_id, and optionalstatus. to_level_idmay benullfor same-level or terminal actions.- Use
statusto control the instance/history status after the action. - Use
meta.complete = trueto explicitly mark an action as terminal. - Use
meta.mark_submitted = trueto stampsubmitted_atwhen needed. - The first level can still be applicant-owned by using assignment criteria
['initiator' => true].
Example transitions:
WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $applicantLevel->id, 'to_level_id' => $supervisorLevel->id, 'action_key' => 'submit', 'label' => 'Submit', 'direction' => 'forward', 'status' => 'in_progress', 'meta' => ['mark_submitted' => true], ]); WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $supervisorLevel->id, 'to_level_id' => $financeLevel->id, 'action_key' => 'approve', 'label' => 'Approve', 'direction' => 'forward', 'status' => 'approved', ]); WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $supervisorLevel->id, 'to_level_id' => $applicantLevel->id, 'action_key' => 'return', 'label' => 'Return for Update', 'direction' => 'backward', 'status' => 'returned', ]); WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $financeLevel->id, 'to_level_id' => null, 'action_key' => 'complete', 'label' => 'Complete', 'direction' => 'stay', 'status' => 'completed', 'meta' => ['complete' => true], ]);
Return or reject to a specific previous level:
WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $financeLevel->id, 'to_level_id' => $applicantLevel->id, 'action_key' => 'reject_to_applicant', 'label' => 'Reject to Applicant', 'direction' => 'backward', 'status' => 'rejected', ]); WorkflowTransition::create([ 'workflow_id' => $workflow->id, 'from_level_id' => $financeLevel->id, 'to_level_id' => $supervisorLevel->id, 'action_key' => 'return_to_supervisor', 'label' => 'Return to Supervisor', 'direction' => 'backward', 'status' => 'returned', ]);
This allows a level to send the workflow back to any earlier level, not only the immediately previous one.
Retrieve Configured Flows
Get all workflow definitions:
use Qnox\Workflows\Models\Workflow; $workflows = Workflow::query() ->with(['levels.assignments', 'transitions']) ->where('is_active', true) ->orderBy('name') ->get();
Get one workflow with its full setup:
$workflow = Workflow::query() ->with([ 'levels.assignments', 'levels.outgoingTransitions', 'transitions', ]) ->where('slug', 'application-approval') ->firstOrFail();
Get the ordered level flow:
$levels = $workflow->levels() ->with(['assignments', 'outgoingTransitions.toLevel']) ->orderBy('sequence') ->get();
Retrieve the Current Flow
Get the active workflow instance for a subject:
use Qnox\Workflows\Models\WorkflowInstance; $instance = WorkflowInstance::query() ->with(['workflow', 'currentLevel', 'history.level', 'actions']) ->where('subject_type', $application::class) ->where('subject_id', $application->getKey()) ->latest('id') ->first();
Load the underlying subject resource from the instance:
$subject = $instance?->subject;
Get the current level of that instance:
$currentLevel = $instance?->currentLevel; $currentStatus = $instance?->status;
Get the current track/history row:
$currentTrack = $instance?->history() ->whereNull('exited_at') ->latest('id') ->first();
Get the actions available to the current user:
$actions = app(WorkflowEngine::class)->availableActions($instance, auth()->user());
Act on the Current Flow
Run a configured action:
$updated = app(WorkflowEngine::class)->act( $instance, 'approve', auth()->user(), ['comment' => 'Reviewed and approved'] );
API Routes
The package registers:
GET /api/workflows/instances/{instance}/actionsPOST /api/workflows/instances/{instance}/actGET /api/workflow-instances/{instance}/actionsPOST /api/workflow-instances/{instance}/act
The last two endpoints are v0 compatibility aliases and can be disabled with
workflows.routes.api.legacy_routes.
Example POST payload:
{
"action_key": "approve",
"payload": {
"comment": "Looks good"
}
}
Status Notes
Statuses are stored on workflow_instances.status, workflow_instance_levels.status, and workflow_actions.status.
Common values are:
pendingin_progressapprovedreturnedrejectedon_holdrecalledcompleted
You may also use your own status values in transitions if your application needs different labels.
Version 1 Administration
Version 1 provides web configuration screens for:
- Module groups
- Business modules
- Workflow definitions
- Ordered workflow levels
- Configured actions and transitions
- Number formats and sequences
- Workflow instance history and action modals
- Per-user workflow inboxes and sidebar counters
Add the settings link to a Blade menu:
@can('workflows.manage') <a href="{{ route('workflows.dashboard') }}">Workflow Settings</a> @endcan
For applications with a menu registry:
use Qnox\Workflows\Support\WorkflowMenu; $menu->register(WorkflowMenu::items());
The package does not mutate the host application's navigation. This keeps it compatible with Blade sidebars, AdminLTE, Spatie menus, Livewire navigation, and custom menu tables.
Workflow Inbox and Sidebar Counters
The package materializes one WorkflowInboxItem for every resolved user when a workflow
enters a level. This makes counters fast and records whether each recipient opened or
responded to the assignment.
Default inbox:
/workflows/inbox
Named links:
workflows.inbox.index
workflows.inbox.new
workflows.inbox.pending
workflows.inbox.attended
workflows.inbox.responded
workflows.inbox.held
workflows.inbox.ended
workflows.inbox.counts
Categories mean:
new: assigned but not yet openedpending: currently assigned and not yet answeredattended: opened but not yet answeredresponded: the user performed a workflow actionheld: a workflow related to the user is currently on holdended: a workflow related to the user has completed
Get all counters:
use Qnox\Workflows\Services\WorkflowInbox; $counts = app(WorkflowInbox::class)->counts(auth()->user());
Example:
[
'new' => 4,
'pending' => 7,
'attended' => 2,
'responded' => 16,
'held' => 1,
'ended' => 42,
]
Add links directly to a Blade sidebar:
@php($workflowCounts = app(\Qnox\Workflows\Services\WorkflowInbox::class)->counts(auth()->user())) <a href="{{ route('workflows.inbox.new') }}"> New workflows @if($workflowCounts['new']) <span class="badge">{{ $workflowCounts['new'] }}</span> @endif </a> <a href="{{ route('workflows.inbox.responded') }}"> Responded <span class="badge">{{ $workflowCounts['responded'] }}</span> </a>
Or use the package menu descriptors:
$items = WorkflowMenu::inbox(auth()->user());
For asynchronous sidebar updates:
GET /workflows/inbox/counts
The response contains the six counter values as JSON. The default inbox is provided by
workflows::inbox.index and can be replaced:
'views' => [ 'inbox' => 'my-application.workflows.inbox', ],
Approvers also receive NextApproverNotification. Configure its channels:
'notify_channels' => ['mail', 'database'],
Mail notifications link to workflows.inbox.show; database notifications contain the
workflow instance, workflow, subject, current level, and status identifiers. Notifications
are queued after the workflow transaction commits.
Inbox routes have their own middleware configuration:
'routes' => [ 'inbox' => [ 'enabled' => true, 'prefix' => 'workflows/inbox', 'middleware' => ['web', 'auth'], ], ],
Modules and Groups
WorkflowGroup organizes related business functions. WorkflowModule represents a
business process, and Workflow represents one executable definition.
$group = WorkflowGroup::create([ 'name' => 'Finance', 'slug' => 'finance', ]); $module = WorkflowModule::create([ 'workflow_group_id' => $group->id, 'name' => 'Payment Requisitions', 'slug' => 'payment-requisitions', ]); $workflow = Workflow::create([ 'workflow_group_id' => $group->id, 'workflow_module_id' => $module->id, 'name' => 'Standard Payment Approval', 'slug' => 'standard-payment-approval', 'is_active' => true, ]);
A module may be attached to application configuration through moduleable_type and
moduleable_id, for example a PaymentRequisitionType.
Model Integration
Add HasWorkflows to resources that participate in workflows:
use Qnox\Workflows\Concerns\HasWorkflows; class PaymentRequisition extends Model { use HasWorkflows; }
Then start and retrieve workflows through the resource:
$instance = $requisition->startWorkflow( $workflow, auth()->user(), [ 'amount' => $requisition->amount, 'department_id' => $requisition->department_id, ], ); $current = $requisition->currentWorkflowInstance();
Lifecycle Events
Version 1 dispatches:
WorkflowStartingWorkflowStartedWorkflowActioningWorkflowActionedWorkflowLevelEnteredWorkflowLevelExitedWorkflowClaimedWorkflowCompletedWorkflowRejectedWorkflowReturnedWorkflowHeldWorkflowResumedWorkflowRecalled
The Starting and Actioning events run synchronously and may stop the operation by
throwing an exception. All other events are registered after the workflow database
transaction commits.
Application-specific behavior belongs in listeners:
use Qnox\Workflows\Events\WorkflowCompleted; class MarkPaymentApproved { public function handle(WorkflowCompleted $event): void { $payment = $event->instance->subject; if ($payment instanceof PaymentRequisition) { $payment->update([ 'status' => 'approved', 'approved_at' => now(), ]); } } }
Register it in the host application's event provider:
protected $listen = [ WorkflowCompleted::class => [ MarkPaymentApproved::class, ], ];
This replaces the large module-ID switch subscriber used by IOO-WEB-V2.
Polymorphic Assignments
Assignments may target any model:
WorkflowAssignment::create([ 'workflow_level_id' => $level->id, 'type' => 'position', 'assignable_type' => Position::class, 'assignable_id' => $position->id, ]);
The host application teaches the package how to resolve that model:
use Qnox\Workflows\Contracts\AssignmentProvider; class PositionAssignmentProvider implements AssignmentProvider { public function users(WorkflowAssignment $assignment, array $context = []): Collection { return User::where('position_id', $assignment->assignable_id)->get(); } public function contains( Authenticatable $user, WorkflowAssignment $assignment, array $context = [] ): bool { return (string) $user->position_id === (string) $assignment->assignable_id; } }
Register providers in the published configuration:
'assignment_providers' => [ 'user' => UserAssignmentProvider::class, 'position' => App\Workflows\PositionAssignmentProvider::class, 'designation' => App\Workflows\DesignationAssignmentProvider::class, 'unit' => App\Workflows\UnitAssignmentProvider::class, 'department' => App\Workflows\DepartmentAssignmentProvider::class, ],
Expose the types in the settings form:
'assignment_options' => [ 'user' => ['label' => 'User', 'model' => App\Models\User::class], 'position' => ['label' => 'Position', 'model' => App\Models\Position::class], 'designation' => ['label' => 'Designation', 'model' => App\Models\Designation::class], 'unit' => ['label' => 'Unit', 'model' => App\Models\Department::class], ],
Use a morph map to avoid storing application class names:
Relation::enforceMorphMap([ 'user' => User::class, 'position' => Position::class, 'unit' => Department::class, 'payment-requisition' => PaymentRequisition::class, ]);
Level Participation and Assignment Modes
Participation permissions and routing assignments are separate:
WorkflowLevelParticipantanswers who is permitted to view, attend, or act at a level.WorkflowAssignmentanswers where a particular workflow item is routed.WorkflowInstanceLevel::assigned_torecords who owns the current track.
When both participant permissions and routing assignments exist, recipients are the intersection:
Users resolved from the routed unit/department
∩
Users permitted for the workflow level
=
Users who receive the workflow item
If a level has no participant permissions, its existing assignments remain the eligibility source for backward compatibility.
Each level has one assignment mode:
pooled
automatic
direct
Pooled
The track enters the level without an owner. All eligible users receive a New inbox item. The first authorized user to select Attend this workflow atomically claims it. Other participants' inbox entries close, and only the claiming user may act.
$level->update(['assignment_mode' => 'pooled']);
Claim route:
POST workflows.inbox.claim
The claim operation locks the active WorkflowInstanceLevel, so two users cannot claim
the same item.
Automatic
The first eligible resolved user is assigned immediately. Only that user receives the active inbox item and may act.
$level->update(['assignment_mode' => 'automatic']);
Direct
The current action must provide payload.next_user_id. The selected user must satisfy
both routing and level-participation rules.
'form_schema' => [ 'fields' => [ [ 'name' => 'next_user_id', 'type' => 'number', 'label' => 'Next user ID', 'required' => true, ], ], ],
The engine rejects a direct transition when the selected user is not eligible.
User Workflow Permissions
The package includes the equivalent of IOO-WEB-V2's User → Workflow Permissions screen:
/settings/workflows/participants
/settings/workflows/participants/users/{user}
Named routes:
workflows.participants.index
workflows.participants.user
workflows.participants.user.update
Link to it from an existing user-management screen:
<a href="{{ route('workflows.participants.user', $user->getAuthIdentifier()) }}"> Workflow Permissions </a>
The administrator can grant a user access to individual workflow levels. Definition screens also provide participant configuration with separate View, Attend, and Act permissions.
Non-user participant types such as positions and units use providers:
'participant_options' => [ 'user' => ['label' => 'User', 'model' => App\Models\User::class], 'position' => ['label' => 'Position', 'model' => App\Models\Position::class], 'unit' => ['label' => 'Unit', 'model' => App\Models\Department::class], ], 'participant_providers' => [ 'user' => UserParticipantProvider::class, 'position' => App\Workflows\PositionParticipantProvider::class, 'unit' => App\Workflows\UnitParticipantProvider::class, ],
Configurable Number Generation
Number sequences replace the IOO sysdef/table-name switch with editable formats and atomic counters.
NumberSequence::create([ 'key' => 'payment-requisition', 'name' => 'Payment Requisition Number', 'format' => '{prefix}/{year}/{number}', 'prefix' => 'QNOX/PR', 'next_value' => 1, 'padding' => 6, 'reset_period' => 'yearly', 'is_active' => true, ]);
Generate a number:
$number = app(NumberGenerator::class)->next('payment-requisition'); // QNOX/PR/2026/000001
Or use the model convenience trait:
use Qnox\Workflows\Concerns\HasWorkflowNumber; class PaymentRequisition extends Model { use HasWorkflowNumber; } $number = $payment->generateWorkflowNumber('payment-requisition');
Supported format tokens are:
{prefix} {number} {year} {year:2} {month} {day}
{module} {department} {unit} {tenant} {subject_id}
Custom tokens receive their values from context:
$number = app(NumberGenerator::class)->next('department-document', [ 'department' => 'FIN', 'module' => 'PR', ]);
Valid reset periods are never, yearly, monthly, and daily. Generation uses a
database transaction and lockForUpdate(), preventing duplicate numbers under
concurrent requests.
Scoped counters are supported:
$number = app(NumberGenerator::class)->next( 'payment-requisition', ['department' => $department->code], $department, );
Create one NumberSequence record for each scope. The scope is stored polymorphically.
Custom Views and Action Modals
Views load under the workflows:: namespace. Override a screen without publishing:
'views' => [ 'instance' => 'payments.workflows.show', 'action_modal' => 'payments.workflows.action-modal', ],
Or publish all views:
php artisan vendor:publish --tag=qnox-workflows-views
Published files are written to:
resources/views/vendor/workflows
Action modals are generated from WorkflowTransition::form_schema:
'form_schema' => [ 'confirmation' => 'Approve and forward this request?', 'fields' => [ [ 'name' => 'comment', 'type' => 'textarea', 'label' => 'Comments', 'required' => true, ], ], ],
To render package actions inside a custom resource view:
@include('workflows::actions.buttons', [ 'instance' => $instance, 'actions' => app(WorkflowEngine::class) ->availableActions($instance, auth()->user()), ])
Manual Testing Checklist
- Open
/settings/workflowsand create a group, module, definition, levels, and transitions. - Create assignments for the start and approval levels.
- Start a workflow using
startWorkflow()orWorkflowEngine::start(). - Sign in as the resolved approver and open
/workflows/inbox/new. - For a pooled level, confirm all eligible participants see New, then click Attend.
- Confirm the item disappears from the other participants and only the claimant can act.
- For an automatic level, confirm only the resolved owner receives the item.
- For a direct level, select an eligible
next_user_idand confirm invalid users are rejected. - Opening an owned item moves it from
newtoattended. - Perform an action from the generated modal.
- Confirm the item appears under
respondedand the next approver receives anewitem. - Complete the workflow and confirm it appears under
ended. - Create a number format under Workflow Settings → Number Formats.
- Generate several numbers and confirm padding and reset behavior.