byrcsc/laravel-approval · 1.x
Workflows and stages.
Define ordered approval stages, choose approval thresholds and rejection policies, and understand the snapshot taken at submission.
A workflow is a slug, a name, and an ordered list of stages. Both are ordinary Eloquent models, so an application can build workflow management screens without going through the package.
use ByRcsc\LaravelApproval\Models\ApprovalWorkflow;
$workflow = ApprovalWorkflow::create([
'name' => 'Purchase orders',
'slug' => 'purchase-order',
'description' => 'Finance, then the board above ₱100,000.',
'active' => true,
]);Workflow slugs use one global namespace. Shared-database multi-tenant
applications must scope lookups themselves or carry the tenant in each slug,
for example acme:purchase-order.
Workflow columns
| Column | |
|---|---|
name | Display name. |
slug | The stable identifier callers name it by. |
description | Free text for management screens. |
active | An inactive workflow refuses new submissions and is never chosen automatically. |
approvable_type | Registers the workflow against a model type, so submissions of that model can find it. |
condition | A WorkflowCondition deciding whether this workflow applies to a submission. |
condition_config | Configuration for that condition. |
priority | Orders equally specific workflows; higher wins. Defaults to 0. |
settings | Per-workflow overrides — see below. |
Four of these — approvable_type, condition, condition_config, and
priority — exist so one model can have more than one workflow. Leave them
alone and a workflow behaves exactly as a single-workflow package would.
Choosing the workflow covers them in full.
Stage columns
$stage = $workflow->stages()->create([
'sequence' => 1,
'name' => 'Finance',
'required_approvals' => 1,
'rejection_policy' => RejectionPolicy::ApprovalImpossible,
'sla_minutes' => 1440,
'remind_every_minutes' => 240,
'escalation_strategy' => EscalationAction::Escalate,
'escalation_config' => ['resolver' => ManagerOf::class],
'condition' => AmountExceeds::class,
'condition_config' => ['threshold' => 100_000],
]);| Column | Meaning |
|---|---|
sequence | Position in the chain, positive and contiguous from one |
name | Display name, carried onto the request snapshot |
required_approvals | How many approvals satisfy the stage; null means all assignments |
rejection_policy | When rejections end the request |
sla_minutes | Deadline, measured from the moment the stage becomes active |
remind_every_minutes | Reminder cadence after the deadline passes |
escalation_strategy | What happens at the deadline |
escalation_config | Configuration for that strategy |
condition | A StageCondition deciding whether the stage applies at all |
condition_config | Configuration for that condition |
Two stages of one workflow cannot share a sequence; the schema enforces it.
Approval thresholds
required_approvals expresses all three common rules:
| Rule | Configuration |
|---|---|
1-of-N | required_approvals = 1 |
N-of-M | required_approvals = N |
| Unanimous | required_approvals = null |
A null rule means "all of them", answered against the assignments the stage actually ends up with — which is only known once approver resolvers have run. Fluent definitions, array definitions, persisted rows, and request snapshots all read omission the same way.
A threshold higher than the number of assignments the stage can produce is a
workflow that cannot reach a decision. Submission refuses it with
InvalidWorkflowException before writing a row.
Rejection policy
The threshold says when a stage passes. The rejection policy says when it fails, and the two are configured separately.
| Policy | Behavior |
|---|---|
approval_impossible | The stage fails once the remaining approvers cannot reach the threshold |
any_rejection_vetoes | Any single rejection ends the request |
approval_impossible is the default. On a 2-of-5 stage it lets three people
say no before the stage is lost; on a unanimous stage it behaves like a veto,
because one rejection already makes the threshold unreachable.
Rejection ends the whole request, not just the stage. Later stages that never ran are marked skipped rather than rejected. The requester may then revise and resubmit.
The snapshot boundary
This is the single most important behavior in the package. Submission copies onto the request:
- the workflow's stages, in order, with every column above;
- each stage's evaluated condition result; and
- the approval assignments, with resolvers already run.
From then on the request runs off its own copy. workflow_id on the request
is a reporting reference only, and is nulled rather than cascaded if the
workflow row is deleted, so history survives.
That means a workflow is freely editable. Adding a stage, changing a threshold, or replacing an approver affects what is submitted next and nothing already in flight. It also means a request can outlive the workflow that produced it.
When a reorganization genuinely must reach in-flight work, that is a deliberate
administrative act:
Approval::resync().
Deactivating a workflow
$workflow->update(['active' => false]);An inactive workflow refuses new submissions with InvalidWorkflowException.
Requests already running are unaffected — they hold their own snapshot.
Workflows soft-delete. A deleted workflow stops being found by
Approval::workflow() while its requests keep their history.
Looking a workflow up
use ByRcsc\LaravelApproval\Facades\Approval;
Approval::workflow('purchase-order'); // throws if missing
Approval::findWorkflow('purchase-order'); // null if missing
Approval::workflowFor($order); // applies the resolution order
Approval::workflowsFor($order); // every workflow that applies
Approval::workflows(); // every active workflowworkflowFor() resolves exactly as submission does: an argument wins outright,
and otherwise candidates are gathered from the model's approvalWorkflow(), the
approval.workflows config map, and the workflows registered against the
model's type, then filtered by active state and condition. When nothing applies
it throws WorkflowNotFoundException, and when two candidates are equally
specific it throws AmbiguousWorkflowException. See
choosing the workflow.
Reading a workflow's configured approvers
$workflow = ApprovalWorkflow::query()
->with('stages.approvers.approver')
->where('slug', 'purchase-order')
->firstOrFail();
$approversByStage = $workflow->stages->mapWithKeys(
fn ($stage) => [
$stage->sequence => $stage->approvers->map(
fn ($entry) => $entry->approver ?? $entry->resolver,
),
],
);Each value is either a concrete approver model or a resolver class-string. A resolver-backed entry cannot name its approvers from the workflow alone — it needs the record, the requester, and the stage configuration, all of which exist only at submission.
Per-workflow settings
The settings JSON column holds per-workflow overrides of package
configuration. Currently one key is read:
$workflow->update(['settings' => ['allow_self_approval' => true]]);This overrides approval.allow_self_approval for requests on this workflow.
Unlike the stage rules, it is read live rather than snapshotted: tightening a
control policy should apply to work already in flight.