NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Approvals for Laravel

Staged pipelines

A request can run as an ordered set of stages. Stage N only opens once stage N−1 has cleared; by default a rejection in any stage rejects the whole request:

use RoundlyConsulting\Approvals\DataTransferObjects\StageDefinition;
use RoundlyConsulting\Approvals\Enums\ApprovalRule;
use RoundlyConsulting\Approvals\Facades\Approvals;

Approvals::request($release)->stages([
    new StageDefinition([$eng1, $eng2], ApprovalRule::Unanimous, name: 'engineering'),
    new StageDefinition([$product],     ApprovalRule::Any,       name: 'product'),
])->expiringAt($deadline)->open();

Approvals::currentStage($release);   // ?ApprovalRequestStage — the open stage
Approvals::progress($release);       // ?ApprovalProgress — counts, current/total stages, percentage()

Passing both from() and stages() to one builder throws InvalidApprovalRequestException. On a RequiresApproval model the same pipeline has a shorthand:

// RequiresApproval shorthand — the same pipeline through the same manager
$release->requestStagedApproval($stages, rejectOnStageRejection: true, expiresAt: $deadline);

$release->currentStage();       // ?ApprovalRequestStage
$release->approvalProgress();   // ?ApprovalProgress

Walking through a pipeline

Approvers decide on the subject as usual. Each decision attaches to the stage that is currently open, and the pipeline advances on its own:

Approvals::for($release)->as($eng1)->approve();
Approvals::for($release)->as($eng2)->approve();   // engineering clears -> ApprovalStageCleared, product opens -> ApprovalStageOpened

Approvals::currentStage($release)->name;       // 'product'
Approvals::progress($release)->percentage();   // 50 — one of two stages cleared

Approvals::for($release)->as($product)->approve();   // the last stage clears -> the request is approved
Approvals::status($release);                         // ApprovalStatus::Approved

Only the open stage’s approvers can decide it — a later stage’s approver, or an outsider, gets UnauthorizedApprovalException until that stage opens. A withdrawal reaches only the open stage too: decisions of a stage that has already settled stay as they are. Once the request is closed or past its expiry, currentStage() returns null.

StageDefinition

new StageDefinition(
    array $approvers,                              // list<Model> — the stage's named approvers; [] lets anyone decide
    ApprovalRule $rule = ApprovalRule::Unanimous,
    ?int $quorum = null,
    ?string $name = null,
    ?int $requiredApprovers = null,                // approvals the stage needs; one per named approver by default
);

// A stage that names nobody: any approver may decide it.
new StageDefinition([], ApprovalRule::Any, requiredApprovers: 1);

Each stage has its own rule, quorum, name and named approvers. It needs one approval per named approver unless requiredApprovers says otherwise, and it may name nobody to let any approver decide it — given a rule that resolves without names (Any, a quorum or requiredApprovers), else opening throws InvalidApprovalRequestException.

Continuing past a rejected stage

Call continueOnRejection() — or pass rejectOnStageRejection: false to the trait — to let the pipeline continue past a rejected stage. Once every stage has settled, the request is approved if at least one stage cleared as approved, otherwise rejected:

Approvals::request($release)->stages([
    new StageDefinition([$legal], ApprovalRule::Any, name: 'legal'),
    new StageDefinition([$cfo, $ceo], ApprovalRule::Quorum, quorum: 1, name: 'executive'),
])->continueOnRejection()->open();

Stages and events

  • The first stage opens immediately and fires ApprovalStageOpened.
  • When a stage clears as approved, ApprovalStageCleared fires and the next stage opens with ApprovalStageOpened — including the stage after a rejected one the pipeline continues past.
  • When a stage settles, the asks still outstanding in it are cancelled — ApprovalCancelled and ApprovalStatusChanged for each.
  • When the last stage settles — or a stage rejection ends the pipeline — ApprovalRequestResolved and ApprovalStatusChanged fire for the request.
  • When your app hands the open stage to other approvers, ApprovalApproversReplaced fires, then ApprovalStageOpened for it again — see below.

Handing a stage to other approvers

When the open stage must go to someone else — “hand it to my manager” — replace its pending approvers in place instead of closing the round and opening a new one. $stage is the stage’s name, from StageDefinition or the workflow preset; only the stage open for decisions can be replaced. Without within() it uses the subject’s open round:

use RoundlyConsulting\Approvals\DataTransferObjects\StageDefinition;
use RoundlyConsulting\Approvals\Facades\Approvals;

$round = Approvals::request($release)->stages([
    new StageDefinition([$eng1, $eng2, $eng3], name: 'engineering'),   // Unanimous by default
    new StageDefinition([$product], name: 'product'),
])->open();

Approvals::for($release)->as($eng1)->approve();

// eng2 and eng3 are out this week: the engineering stage goes to $manager.
$round = Approvals::for($release)->within($round)->replaceApprovers('engineering', [$manager]);

// The stage now names eng1 (decided, still counting) and $manager, and needs both.
Approvals::for($release)->as($eng2)->approve();      // UnauthorizedApprovalException — no longer named
Approvals::for($release)->as($manager)->approve();   // engineering clears, product opens
  • The new list is the approvers who already decided the stage — approved or rejected, also through a delegate — followed by the ones you pass, each weighed on the subject as when a round opens. The approvers who had not decided are dropped.
  • A stage that needed every named approver keeps needing every one, so its required count follows the new list; an explicit required count stays as it was. The rule and quorum stay, and the round’s own required total follows its stages.
  • The dropped approvers’ outstanding asks are withdrawn — ApprovalCancelled and ApprovalStatusChanged (pending → cancelled) for each. An approver named again keeps its ask.
  • ApprovalApproversReplaced fires with the stage and the approvers added and removed, then ApprovalStageOpened for the stage again, carrying the new approvers, so a listener can notify them.
  • It keeps the decisions already made, the round’s absolute expiry, the stage’s opening time and the round’s workflow — and never resolves the round: neither ApprovalRequestResolved nor ApprovalStageCleared fires.
  • No actor is involved and no authorization gate runs: check who may reassign the stage before you call it. The new approvers still pass the gate when they decide.

Every refusal throws before anything is written, so nothing changes, no event fires and Approvals::fake() records nothing:

Refused whenException (factory)
The approver list is empty — a stage without names would let anyone decide it.InvalidApprovalRequestException (noReplacementApprovers)
The round is closed — approved, rejected, cancelled or expired — or every round of the subject is.ClosedApprovalRequestException (for)
The round is past its expiry; it is not lapsed here, because that would resolve it.ClosedApprovalRequestException (overdue)
The round is flat.InvalidApprovalRequestException (notStaged)
No stage has that name.InvalidApprovalRequestException (unknownStage)
The stage settled already, or has not opened yet.InvalidApprovalRequestException (stageNotOpen)
An approver in the list already decided the stage — its decision stands.InvalidApprovalRequestException (alreadyDecided)
An approver was never saved.InvalidApprovalRequestException (unsavedApprover)
An explicit required count is above the new list’s length, or a quorum or weighted threshold the new list can never reach.InvalidApprovalRequestException (moreRequiredThanNamed, unreachableThreshold)
The decisions already made would settle the stage at once under its rule.InvalidApprovalRequestException (replacementSettlesStage)
The within() round belongs to another subject, or the subject never had a round.InvalidApprovalRequestException (foreignSubject, noRound)
as(), because(), weight() or an expiry was set.InvalidApprovalRequestException (settingsOnReplace)

A flat round — one without stages — is handed over with replaceFlatApprovers(); see Decision guards. An excluded approver can’t be named by either replacement (excludedApprover).

The replacement writes in one transaction under the round’s row lock — the same lock every decision takes before it writes. A decision or ask from an approver the replacement dropped, or from that approver’s delegate, that was still in flight is refused with UnauthorizedApprovalException when it reaches the lock, rather than recorded. A delegate of a new approver can decide for it.

The stage model

$stage = Approvals::currentStage($release);   // ?ApprovalRequestStage

$stage->position;             // 1-based order in the pipeline
$stage->name;                 // 'engineering'
$stage->rule;                 // ApprovalRule
$stage->required_approvers;   // approvals the stage needs
$stage->namedApprovers();     // list<NamedApprover> — empty when it names nobody
$stage->status;               // ApprovalStatus
$stage->opened_at;            // ?CarbonImmutable
$stage->cleared_at;           // ?CarbonImmutable — set when the stage settles
$stage->isOpen();             // pending and opened
$stage->decisions;            // decisions recorded against this stage
$stage->request;              // the ApprovalRequest

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.