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.

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.

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 the last stage settles — or a stage rejection ends the pipeline — ApprovalRequestResolved and ApprovalStatusChanged fire for the request.

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.