Multi-approver requests
Open a request for a subject with Approvals::request(), name the approvers with from(), pick a rule and call open():
use RoundlyConsulting\Approvals\Facades\Approvals;
// Unanimous (the default): every approver must approve; one rejection rejects the request.
Approvals::request($release)->from([$lead, $qa, $pm])->open();
// Quorum: 2 of 3 approvals resolve it; it rejects once 2 approvals are no longer reachable.
Approvals::request($release)->from([$lead, $qa, $pm])->quorum(2)->open();
// Any: the first approval resolves it.
Approvals::request($release)->from([$lead, $qa, $pm])->any()->open();
// Weighted: resolves once the summed approver weight reaches 3.
Approvals::request($release)->from([$lead, $qa, $pm])->weighted(3)->open();With RequiresApproval on the subject, requestApproval() is shorthand for the same call:
use RoundlyConsulting\Approvals\Enums\ApprovalRule;
// RequiresApproval shorthand — the same request through the same manager
$release->requestApproval([$lead, $qa, $pm], ApprovalRule::Quorum, quorum: 2);Approvers decide on the subject as usual. Each decision flows into the open request, and the request resolves itself the moment the rule is met:
Approvals::request($release)->from([$lead, $qa, $pm])->open(); // Unanimous by default
Approvals::for($release)->as($lead)->approve(); // decisions flow into the open request automatically
Approvals::for($release)->as($qa)->approve();
Approvals::status($release); // ApprovalStatus::Pending — one approval still missing
Approvals::for($release)->as($pm)->approve();
Approvals::status($release); // ApprovalStatus::Approved
$release->isApproved(); // true — RequiresApproval shorthandOnly the named approvers decide
The approvers you pass — to from(), requestApproval(), a StageDefinition or a workflow’s open() — are stored on the request, per stage for a staged request. Only they, or a delegate acting for one of them, can decide it. Anyone else gets UnauthorizedApprovalException from approve(), reject(), toggle() and ask(), and nothing is recorded:
use RoundlyConsulting\Approvals\Facades\Approvals;
$request = Approvals::request($release)->from([$lead, $qa, $pm])->quorum(2)->open();
Approvals::for($release)->as($intern)->approve(); // throws UnauthorizedApprovalException — not a named approver
$request->namedApprovers(); // list<NamedApprover>A request opened without named approvers — from([]), or an ApprovalRequest you create yourself with only required_approvers — keeps open semantics: any approver’s decision counts, until required_approvers of them have decided. Each approver is stored once, a request can’t require more approvals than it names, and a quorum or weighted threshold its approvers could never reach is refused when it opens — all with InvalidApprovalRequestException.
How rules resolve
Unanimous and Any count heads; Quorum and Weighted sum decision weights, which with the default weight of 1 is a plain head-count too. required is the number of approvals the request needs — one per named approver unless set otherwise; the threshold is the quorum, or required when no quorum is given:
| Rule | Approved when | Rejected when |
|---|---|---|
Unanimous | approvals ≥ required (a headcount) | any rejection |
Quorum | approved weight ≥ threshold | the threshold is out of reach: approved weight + outstanding weight < threshold |
Any | the first approval | every approver decided without an approval |
Weighted | approved weight ≥ threshold | as Quorum — without named approvers only once every slot has decided |
Which request a decision joins
A decision attaches to the approvable’s latest open request. To attach it to one specific request, pin it with within($request); the request must belong to the approvable, otherwise InvalidApprovalRequestException is thrown and nothing is written. A model that never had a request keeps taking standalone decisions.
Closed requests
Once a request is closed — approved, rejected, cancelled or expired — its round is over and nothing more is recorded on it. A late approve(), reject(), toggle() or ask() on its subject (or pinned to it with within()), and a cancel() that would withdraw one of its decisions, throw ClosedApprovalRequestException — for a delegate too. The exception’s $request property is the closed request. Repeating the decision an actor already holds there stays a harmless no-op that returns it:
$release->requestApproval([$lead]);
$lead->approve($release); // resolves the request as approved
$lead->approve($release); // no-op: returns the same approval
$lead->reject($release); // throws ClosedApprovalRequestException
$lead->cancelApproval($release); // throws ClosedApprovalRequestException
$release->requestApproval([$lead]); // a new round: decisions count towards it againProgress
Approvals::progress() — or approvalProgress() on a RequiresApproval model — returns a read-only snapshot of the latest request:
$progress = Approvals::progress($release); // null when the subject has no request
$progress->status; // ApprovalStatus
$progress->approved; // approvals in — summed weight under Quorum and Weighted
$progress->required; // approvals the request needs
$progress->threshold; // quorum / weight threshold, or null
$progress->ratio(); // float 0.0–1.0
$progress->percentage(); // int 0–100| Member | Meaning |
|---|---|
status | The request’s ApprovalStatus. |
approved / rejected | Approvals / rejections in — summed weight under Quorum and Weighted, a headcount otherwise; of the open stage, for staged requests. |
required | Approvals the request needs — of the open stage, for staged requests. |
threshold | The quorum / weight threshold, or null. |
currentStage | Position of the open stage, or null. |
totalStages | Number of stages — 0 for a flat request. |
clearedStages | Stages cleared as approved. |
ratio() | 0.0–1.0 — cleared/total stages when staged, approved/threshold otherwise. |
percentage() | ratio() rounded to a whole percent. |
The request model
$request = Approvals::request($release)->from([$lead, $qa, $pm])->quorum(2)->open();
$request->rule; // ApprovalRule::Quorum
$request->quorum; // 2
$request->required_approvers; // 3
$request->namedApprovers(); // list<NamedApprover> — empty when opened without names
$request->status; // ApprovalStatus::Pending
$request->decisions; // decisions attached to this request
$request->resolved_at; // ?CarbonImmutable — set when it resolves
$request->isOverdue(); // bool — still pending, but past expires_at
$request->resolve(); // re-evaluate and persist; a final request is returned unchangedResolution fires ApprovalRequestResolved and the umbrella ApprovalStatusChanged, exactly once — a resolving request is finalized with a conditional update, so concurrent decisions resolve it once. The decision actions call resolve() for you; call it yourself only after attaching decisions out-of-band.
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 cryptoBy 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.