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_type | Model class or morph alias this workflow is registered against. |
condition | A WorkflowCondition class deciding whether it applies. |
condition_config | Configuration passed to that condition. |
priority | Higher wins among equally specific workflows. Defaults to 0. |
active | Defaults 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 method | Sets |
|---|---|
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 method | Sets |
|---|---|
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:syncreadsdefinitionsas 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:syncThe 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-emptyWhat 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