The Approvals facade
Everything goes through one API — the Approvals facade, the injectable ApprovalsManager behind it, or the action classes it runs. All three execute the same code, and the facade is the recommended entry point. It is registered automatically. The full surface:
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
use RoundlyConsulting\Approvals\Facades\Approvals;
// One actor's decision on one approvable
Approvals::for($deployment)->as($user)->because('Looks good to me')->approve();
Approvals::for($deployment)->as($user)->because('Please add tests')->reject(); // withdraws the approval above
Approvals::for($deployment)->as($user)->ask(); // ask the actor: a pending decision
Approvals::for($deployment)->as($user)->cancel(); // withdraw the actor's live decision
Approvals::for($deployment)->as($user)->toggle(); // simple on/off
Approvals::for($deployment)->as($user)->expiresIn(86400)->approve(); // valid for a day
Approvals::for($deployment)->as($user)->weight(3)->approve(); // override the decision's weight
Approvals::for($invoice)->as($user)->within($request)->approve(); // pin to one request
Approvals::for($purchase)->close(); // your app calls off the open round — no actor
Approvals::for($release)->replaceApprovers('engineering', [$manager]); // hand the open stage to other approvers — no actor
Approvals::for($invoice)->replaceFlatApprovers([$manager]); // the same for a round without stages
Approvals::for($invoice)->voidIfChanged(); // void rounds whose subject changed since they opened
Approvals::for($invoice)->recheckThreshold(ApprovalStatus::Cancelled); // close a round that can no longer resolve
// Reads
Approvals::for($deployment)->as($user)->isApproved(); // bool
Approvals::for($deployment)->as($user)->isRejected(); // bool
Approvals::for($deployment)->hasPending(); // bool — any pending decision
// Multi-approver requests (flat, staged, or from a workflow preset)
Approvals::request($invoice)->from([$a, $b, $c])->quorum(2)->expiresIn(3600)->open();
Approvals::request($release)->stages([...])->continueOnRejection()->expiringAt($t)->open();
Approvals::request($budget)->workflow('payout')->expiresIn(3600)->open([$a, $b, $c]);
Approvals::request($invoice)->excluding([$requester])->from([$a, $b])->open(); // the requester can't decide
Approvals::status($invoice); // ApprovalStatus of the latest request (Pending when none)
Approvals::subjectChanged($invoice); // bool — the subject changed since its open round opened
Approvals::progress($invoice); // ?ApprovalProgress
Approvals::currentStage($release); // ?ApprovalRequestStage
Approvals::preset('payout'); // WorkflowPreset from config
// Delegation
Approvals::delegations($boss)->to($deputy)->from($monday)->until($friday)->grant();
Approvals::delegations($boss)->revoke($deputy); // or ->revoke() for all; returns int
Approvals::delegations($boss)->active(); // Collection<ApprovalDelegation>
Approvals::delegationFor($deputy); // ?ApprovalDelegation in force now
// Housekeeping
Approvals::expire(); // lapse overdue asks, approvals and requests; returns int
Approvals::expire(subjectType: Invoice::class); // only those on invoices (class or morph alias)Facade methods
| Method | Returns | Purpose |
|---|---|---|
for($approvable) | PendingApproval | Start a decision on an approvable; set the actor with as(). |
as($actor) | PendingApproval | Start a decision by an actor; set the approvable with for(). |
request($subject) | PendingApprovalRequest | Open a multi-approver request — flat, staged or from a preset. |
status($subject) | ApprovalStatus | Status of the subject’s latest request; Pending when it has none, Expired once an open request is past its expiry. |
subjectChanged($subject) | bool | Whether the subject’s open round stored a fingerprint that no longer matches the subject’s — see Decision guards. |
progress($subject) | ?ApprovalProgress | Progress snapshot of the latest request; its status reads Expired once the request is overdue. |
currentStage($subject) | ?ApprovalRequestStage | The open stage of the latest staged request; null once it is closed or overdue. |
preset($name) | WorkflowPreset | Resolve and validate a preset from config('approvals.workflows'). |
delegations($delegator) | DelegationsHandle | Grant, revoke and list one delegator’s delegations. |
delegationFor($delegate, ?$at) | ?ApprovalDelegation | The delegation the model acts under at $at (now when omitted). |
expire(?$now, ?$subjectType) | int | Lapse overdue asks, approvals and requests; returns how many. With $subjectType (a model class or morph alias) only those on that type. |
fake() | ApprovalsFake | Swap in the recording fake; recorded() and every assert*() are then callable on the facade too (see Testing). |
Decisions — PendingApproval
Start from the approvable with for() or from the actor with as(), chain the context, then call a terminal method. A request that names its approvers refuses anyone else with UnauthorizedApprovalException, and a closed request refuses every decision with ClosedApprovalRequestException:
| Method | Returns | Purpose |
|---|---|---|
for($approvable) | self | Set the model being decided on. |
as($actor) | self | Set the deciding model. close(), replaceApprovers() and the guard calls — replaceFlatApprovers(), voidIfChanged(), recheckThreshold() — refuse one. |
within($request) | self | Pin the decision to this request instead of the approvable’s latest open one. It must belong to the approvable (else InvalidApprovalRequestException) and still be open (else ClosedApprovalRequestException). With close(), only this request is closed; with replaceApprovers(), its stage is the one replaced; the guard calls act on this round only. |
because($reason) | self | Attach a reason — the decision’s (approve(), reject(), ask(), toggling on) or the withdrawal’s (cancel(), toggling off). An answer to an ask records only its own reason, or none. close(), replaceApprovers() and the guard calls refuse one; because(null) is no reason. |
weight($n) | self | Override the weight this decision carries — approve(), reject() and toggling on. ask(), cancel(), close(), replaceApprovers() and the guard calls refuse it. |
expiresIn($seconds) | self | Expire N seconds from now — applied by approve(), ask() (as its reply-by deadline) and toggling on. reject(), cancel(), close(), replaceApprovers() and the guard calls refuse it. |
expiringAt($at) | self | Absolute expiry — applied and refused by the same calls as expiresIn(). |
approve() | Approval | Record an approval. It counts towards the pinned request, else the approvable’s latest open request; repeating it is a no-op. |
reject() | Approval | Record a rejection with because() and weight() — an approval the actor held is withdrawn. A rejection has no expiry, so an expiry set before it throws InvalidApprovalRequestException. |
ask() | Approval | Ask the actor for a pending decision, with because() as its reason and the expiry as its reply-by deadline; returns the actor’s live decision unchanged when it already holds one. weight() throws InvalidApprovalRequestException. |
cancel() | ?Approval | Withdraw the actor’s live decision — or one it made as a delegate under a delegation still in force — in the round a decision would land in (only the open stage of a staged request), with because() as the withdrawal’s reason; null when there is none. weight() or an expiry throws InvalidApprovalRequestException. |
close(?$outcome) | int | Close the approvable’s open rounds from your app — or only the within() request — as Cancelled (the default) or Expired. Needs only for(); returns how many rounds this call closed, 0 when none was open. because(), as(), weight() and an expiry throw InvalidApprovalRequestException. |
replaceApprovers($stage, [...]) | ApprovalRequest | Hand the open stage $stage (by name) of the approvable’s open round — or of the within() round — to other approvers, in place; returns the round, read back. Needs only for(). Decisions already made stand and keep counting; as(), because(), weight() and an expiry throw InvalidApprovalRequestException. No gate runs — see Staged pipelines. |
replaceFlatApprovers([...]) | ApprovalRequest | The same for a round without stages: hand the open flat round — or the within() round — to other approvers in place; returns the round. Needs only for(); no gate runs — see Decision guards. |
voidIfChanged() | int | Void — close as cancelled — the approvable’s open rounds, or only the within() round, whose subject fingerprint changed since they opened; returns how many. Needs only for(). |
recheckThreshold(?$closeAs) | ?ThresholdVerdict | Whether the open round — or the within() round — can still reach its threshold with the approvers who can still decide; with Cancelled or Rejected an unreachable round is closed. null when no round is open. Needs only for(). |
toggle() | bool | Simple on/off in the within() request, else the open one — true approved (through approve(), taking because(), weight() and the expiry), false withdrawn (firing ApprovalCancelled, with because() as the withdrawal’s reason). |
isApproved() | bool | Whether the actor holds an approval of the approvable that is still in force. |
isRejected() | bool | Whether the actor holds a rejection for the approvable. |
hasPending() | bool | Whether the approvable has any pending decision not yet past its deadline — needs only for(). |
Requests — PendingApprovalRequest
| Method | Returns | Purpose |
|---|---|---|
from([...]) | self | The named approvers of a flat request — only they (or their delegates) can decide it. Each is stored once; required_approvers defaults to their count. |
rule($rule, ?$quorum) | self | The flat request’s rule and threshold — Unanimous by default. |
any() / quorum($n) / weighted($n) | self | Shortcuts for rule(Any), rule(Quorum, $n) and rule(Weighted, $n). |
stages([StageDefinition, ...]) | self | Make it a staged request. Combined with from(), open() throws InvalidApprovalRequestException. |
continueOnRejection() | self | Staged: keep going past a rejected stage. |
excluding([...]) | self | Exclude these actors — the requester, say — from deciding the round: they can neither decide nor be asked, can’t act as a delegate, and no delegate decides for them; naming one as an approver is refused. Carries over to workflow(); each call replaces the list. |
expiresIn($s) / expiringAt($t) | self | Stamp expires_at on the request. Set before workflow(), it carries over and replaces the preset’s expiry. |
open() | ApprovalRequest | Open the flat or staged request; a threshold its approvers could never reach is refused, and so is a request its rule could never approve — e.g. no names under Unanimous. |
workflow($name)->open([...]) | ApprovalRequest | Open from a named preset — a flat list, or one list per stage. workflow() throws InvalidApprovalRequestException when from(), rule(), any(), quorum(), weighted(), stages() or continueOnRejection() was set first: the preset defines them. |
workflow($name)->excluding([...]) | PendingWorkflowRequest | Exclude actors from the preset’s round, as excluding() does — it replaces a list set before workflow(). |
workflow($name)->expiresIn($s) / expiringAt($t) | PendingWorkflowRequest | Give this round its own deadline instead of the preset’s expiry; the last expiry set wins. |
Delegation — DelegationsHandle
| Method | Returns | Purpose |
|---|---|---|
delegations($d)->to($delegate) | PendingDelegation | Start a delegation — nothing is written until grant(). |
->from($t) / ->until($t) / ->for($seconds) | PendingDelegation | Set the window; for() counts from the start (now without from()). |
->grant() | ApprovalDelegation | Validate the window, save it and fire ApprovalDelegated. |
delegations($d)->revoke(?$delegate) | int | Revoke this delegator’s active and scheduled delegations — or only those to $delegate. |
delegations($d)->active(?$at) | Collection<ApprovalDelegation> | Delegations in force at $at (now), newest first. |
Pinning to a request, the approvals() helper, absolute expiry, incomplete builders and the settings each call refuses are covered under Fluent builders; the same API without the facade is under DI and actions.
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.