Browse documentationOpen

byrcsc/laravel-approval · 1.x

Workflow definitions.

Keep workflows in version control, preview changes with a dry run, and understand which drift the sync refuses.

Workflow rows are freely editable, which suits an application with a workflow management screen. An application whose approval rules belong in code instead declares them as definitions and reconciles them with approval:sync.

Array definitions

// config/approval.php
'definitions' => [
    'purchase-order' => [
        'name' => 'Purchase orders',
        'description' => 'Finance, then the board above ₱100,000.',
        'stages' => [
            [
                'name' => 'Finance',
                'required_approvals' => 1,
                'sla_minutes' => 1440,
                'approvers' => [
                    ['resolver' => App\Approvals\FinanceTeam::class],
                ],
            ],
            [
                'name' => 'Board',
                'required_approvals' => 2,
                'rejection_policy' => 'any_rejection_vetoes',
                'condition' => App\Approvals\AmountExceeds::class,
                'condition_config' => ['threshold' => 100_000],
                'approvers' => [
                    ['resolver' => App\Approvals\BoardMembers::class],
                ],
            ],
        ],
    ],
],

The array key becomes the slug when the definition does not carry one. Sequences default to array order and must be contiguous from one.

Workflow selection keys

A definition can also carry the keys that decide when it is chosen, which is what puts workflow selection under version control alongside the stages:

'purchase-order-executive' => [
    'name' => 'Executive purchase orders',
    'approvable_type' => App\Models\PurchaseOrder::class,
    'condition' => App\Approvals\AmountAbove::class,
    'condition_config' => ['amount' => 10_000],
    'priority' => 10,
    'active' => true,
    'stages' => [/* … */],
],
Key
approvable_typeModel class or morph alias this workflow is registered against.
conditionA WorkflowCondition class deciding whether it applies.
condition_configConfiguration passed to that condition.
priorityHigher wins among equally specific workflows. Defaults to 0.
activeDefaults to true. An inactive workflow is never chosen.

condition is validated at definition time: a class that does not exist or does not implement WorkflowCondition raises InvalidWorkflowDefinitionException, as does a negative priority.

Fluent definitions

WorkflowBuilder produces the same structure with a typed API:

use ByRcsc\LaravelApproval\Definitions\WorkflowBuilder;
use ByRcsc\LaravelApproval\Enums\EscalationAction;
use ByRcsc\LaravelApproval\Enums\RejectionPolicy;

WorkflowBuilder::make('purchase-order', 'Purchase orders')
    ->description('Finance, then the board above ₱100,000.')
    ->settings(['allow_self_approval' => false])
    ->stage('Finance', fn ($stage) => $stage
        ->minimumApprovals(1)
        ->sla(1440)
        ->remindersEvery(240)
        ->escalate(EscalationAction::Escalate, ['resolver' => ManagerOf::class])
        ->resolver(FinanceTeam::class))
    ->stage('Board', fn ($stage) => $stage
        ->minimumApprovals(2)
        ->rejectionPolicy(RejectionPolicy::AnyRejectionVetoes)
        ->when(AmountExceeds::class, ['threshold' => 100_000])
        ->approver($chairperson)
        ->resolver(BoardMembers::class));
Stage methodSets
minimumApprovals(?int)required_approvals; null means all
rejectionPolicy(RejectionPolicy)rejection_policy
approver(Model)A concrete approver entry
resolver(string, array)A resolver entry
sla(?int)sla_minutes
remindersEvery(?int)remind_every_minutes
escalate(EscalationAction, array)Escalation strategy and config
when(string, array)Condition class and config

Stages take their sequence from the order they are declared.

The builder has workflow-level methods for the selection keys above:

WorkflowBuilder::make('purchase-order-executive', 'Executive purchase orders')
    ->forApprovable(PurchaseOrder::class)
    ->when(AmountAbove::class, ['amount' => 10_000])
    ->priority(10)
    ->stage('Finance', fn ($stage) => $stage->approver($cfo));
Workflow methodSets
description(?string)description
active(bool)active; defaults to true
forApprovable(Model|string)approvable_type
when(string, array)condition and condition_config
priority(int)priority
settings(array)settings

when() appears in both tables and means different things by level. On the workflow it decides whether this workflow runs at all; on a stage it decides whether that stage applies once the workflow is already running. See choosing the workflow and conditional stages.

Builders can go straight into the config array, or be passed to Approval::syncDefinitions() directly.

Key every definition by slug. approval:sync reads definitions as a string-keyed map. An unkeyed list — 'definitions' => [WorkflowBuilder::make('purchase-order')] — is silently dropped, and the command reports everything already in sync.

Validation at definition time

Definitions are validated as they are built, so a mistake surfaces where it was written rather than at the next submission. InvalidWorkflowDefinitionException covers:

  • blank slugs, names, or stage names;
  • non-positive sequences, thresholds, SLAs, or reminder cadences;
  • sequences that are not unique and contiguous from one;
  • a stage with no approvers;
  • an approver entry naming both a model and a resolver, or neither;
  • a concrete approver that is not persisted, or whose record does not exist;
  • a resolver that does not implement ApproverResolver;
  • a condition that does not implement StageCondition;
  • an unknown escalation strategy or rejection policy;
  • configuration arrays with non-string keys;
  • one slug defined more than once.

Concrete approvers are checked against the database, so an approver that has since been deleted fails the sync rather than producing a workflow nobody can satisfy.

Previewing

php artisan approval:sync --dry-run
  create ......... workflow:purchase-order
  create ......... stage:purchase-order:1
  create ......... approver:purchase-order:1:resolver:App\Approvals\FinanceTeam:null
  Dry run complete; no approval workflow records were changed.

Nothing is written. Run it in CI to fail a build whose definitions drifted from the deployed workflows.

Applying

php artisan approval:sync

The whole reconciliation runs in one transaction. The command exits non-zero when anything was refused.

Approval::syncDefinitions($definitions, $dryRun) is the programmatic form and returns a WorkflowSyncResult:

$result->created;      // list<string>
$result->updated;
$result->unchanged;
$result->refused;
$result->dryRun;
$result->successful(); // refused === []
$result->changed();    // created or updated is non-empty

What the sync refuses

Additive and supported configuration changes are applied. Destructive drift is refused rather than performed:

  • removing a stage that exists in the database but not in the definition;
  • reordering existing stages;
  • removing an approver entry from an existing stage.

Those are changes with consequences the package cannot judge — a stage removed from a definition might be a deliberate simplification or a typo in a config file. Make them deliberately, against the workflow rows.

A refusal aborts the whole sync before writing anything, so a partially applied reconciliation is not a state you can end up in.

In-flight requests are untouched

Syncing edits workflow rows. Requests already running hold their own snapshot and are unaffected — including requests on a workflow the sync just changed.

To push a workflow change into a live request, that is a separate, attributable act: Approval::resync().

Deploy sequence

A workable order for an application whose workflows live in code:

php artisan approval:sync --dry-run   # in CI, on the pull request
php artisan migrate --force
php artisan approval:sync             # after deploy
php artisan approval:status           # confirm nothing needs attention