Decision guards
Four opt-in guards, since 2.2.0, for rounds whose approvers or subject can change after the round opened. Nothing changes for an app that uses none of them: no config is needed, existing rounds and decisions keep working, and migration 0008 only adds nullable columns (see Installation).
| Guard | Turn it on with |
|---|---|
| Excluding actors from deciding | ->excluding([...]) on a round, and/or the exclusions.resolver config key |
| Tying a round to one version of its subject | the subject implements ProvidesApprovalFingerprint; fingerprint.on_change |
| Replacing a flat round’s approvers | ->replaceFlatApprovers([...]) |
| Re-checking that a round can still reach its threshold | ->recheckThreshold(?$closeAs) |
All of them write under the round’s row lock — the one every decision takes before it writes. See Concurrency below.
Excluding actors from deciding
Keep someone from deciding on something they have a stake in — the requester of a round, or the author of the approvable. Per round, pass the actors to excluding() when you open it; they are stored on the round:
use RoundlyConsulting\Approvals\Facades\Approvals;
// Keep the requester from approving their own invoice
Approvals::request($invoice)
->excluding([auth()->user()])
->from([$controller, $cfo])
->quorum(1)
->open();
// Carries over to a workflow preset — or set it after workflow()
Approvals::request($invoice)->excluding([$requester])->workflow('payout')->open([$a, $b]);
Approvals::request($invoice)->workflow('payout')->excluding([$requester])->open([$a, $b]);
Approvals::for($invoice)->as($requester)->approve(); // UnauthorizedApprovalException — excludedEach call to excluding() replaces the list, and an unsaved actor throws InvalidApprovalRequestException (unsavedExcludedActor). The raw actions take the same list — ApprovalRequestData’s excluded, and the excluded argument of RequestStagedApprovalAction and OpenWorkflowRequestAction.
App-wide, point exclusions.resolver at a class implementing ResolvesExcludedApprovers. It is resolved from the container on each check, so it can take constructor dependencies:
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Approvals\Interfaces\ResolvesExcludedApprovers;
use RoundlyConsulting\Approvals\Models\ApprovalRequest;
final class AuthorsCannotApprove implements ResolvesExcludedApprovers
{
/** @return iterable<Model> */
public function excludedApprovers(Model $approvable, ?ApprovalRequest $request): iterable
{
return $approvable instanceof Invoice && $approvable->author !== null ? [$approvable->author] : [];
}
}
// config/approvals.php
'exclusions' => ['resolver' => AuthorsCannotApprove::class],$request is the round the decision lands on — null for a standalone decision and while a round is being opened. Entries that are not saved models are skipped. A blank or null value is no resolver; anything that is not an implementation throws InvalidConfigurationException naming the key. What an excluded actor can’t do, from either source:
| An excluded actor tries to… | Result |
|---|---|
| approve(), reject(), ask() or toggle on | UnauthorizedApprovalException (excluded) |
| decide as the delegate of a named approver | UnauthorizedApprovalException (excluded) |
| have a delegate decide for it — on a round without names, where an active delegation makes the delegate decide for its delegator | UnauthorizedApprovalException (excludedPrincipal) |
| be named as an approver when a round opens, or by replaceApprovers() / replaceFlatApprovers() | InvalidApprovalRequestException (excludedApprover) |
| cancel() a decision it holds — one recorded before it was excluded | allowed: a withdrawal only removes its influence |
A delegate that stands in for an excluded approver and for another named approver decides for the other one. Exclusions apply to standalone decisions too, through the resolver. They don’t retire decisions already recorded: a decision made before the actor was excluded keeps counting until it is withdrawn.
Tying a round to one version of its subject
An approval is given on what the approver saw. When the subject changes afterwards — an invoice’s amount, a contract’s text — the round shouldn’t count as approval of the new version. Implement ProvidesApprovalFingerprint on the subject. The fingerprint is any string up to 255 characters that changes whenever the part the approvers sign off on changes — a version number, an updated_at stamp or a digest you compute:
use RoundlyConsulting\Approvals\Interfaces\ProvidesApprovalFingerprint;
final class Invoice extends Model implements ProvidesApprovalFingerprint
{
public function approvalFingerprint(): string
{
return sha1($this->amount.'|'.$this->currency.'|'.$this->payee_id);
}
}A longer value throws InvalidApprovalRequestException (fingerprintTooLong). The round stores the fingerprint when it opens, and every decision and ask stores the one it was made on, so each sign-off records the version it was given on. The value is read from a fresh copy of the subject, so a stale model in memory never vouches for a version that changed in the database. A subject without the interface stores null, and a round opened without a fingerprint — or before 2.2.0 — never reads as changed.
use RoundlyConsulting\Approvals\Facades\Approvals;
Approvals::subjectChanged($invoice); // bool — the open round's fingerprint differs
Approvals::for($invoice)->voidIfChanged(); // int — rounds voided
Approvals::for($invoice)->within($round)->voidIfChanged(); // only that roundvoidIfChanged() closes each open round whose stored fingerprint differs from the subject’s current one as cancelled, the way close() does — its outstanding asks are retired, ApprovalRequestResolved and ApprovalStatusChanged fire — then fires ApprovalRequestVoided with the stored and the current fingerprint. Rounds that still match, rounds without a fingerprint and closed rounds are left alone; a round past its expiry lapses as expired instead. Call it from the subject’s updated observer, or before you act on an approval.
Decisions on a changed subject follow fingerprint.on_change. Withdrawals — cancel() and toggling off — are never refused:
| fingerprint.on_change | A decision on a round whose subject changed |
|---|---|
ignore | is recorded, as before, and stores the fingerprint it was made on — the default |
refuse | throws ChangedApprovalSubjectException ($e->voided is false); the round stays open |
void | voids the round, as voidIfChanged() does, then throws ChangedApprovalSubjectException ($e->voided is true) |
# .env — what a decision on a changed subject does: ignore (default), refuse or void
APPROVALS_FINGERPRINT_ON_CHANGE=refuseuse RoundlyConsulting\Approvals\Exceptions\ChangedApprovalSubjectException;
use RoundlyConsulting\Approvals\Facades\Approvals;
try {
Approvals::for($invoice)->as($cfo)->approve();
} catch (ChangedApprovalSubjectException $e) {
$e->request; // the round
$e->voided; // true under void — the round was closed as cancelled; false under refuse
}A blank value is the default, ignore; any other value throws InvalidConfigurationException. The next major plans to make refuse the default, so a subject that provides a fingerprint never takes a decision on a version its round didn’t open on.
Replacing a flat round’s approvers
replaceApprovers() hands a stage to other approvers (see Staged pipelines); replaceFlatApprovers() does the same for a round without stages:
use RoundlyConsulting\Approvals\Facades\Approvals;
Approvals::for($invoice)->replaceFlatApprovers([$manager]);
Approvals::for($invoice)->within($round)->replaceFlatApprovers([$manager, $deputy]);- The round must be pending, within its expiry, and flat — a staged round throws InvalidApprovalRequestException (staged), pointing at replaceApprovers().
- The new list is the approvers who already decided the round — a decision in force, approved or rejected — then the ones you pass, each weighed on the subject. On a round opened without names, the actors who decided it are kept the same way and the round becomes named.
- A round that needed every named approver keeps needing every one; an explicit required count stays. The rule, quorum and expiry stay.
- The dropped approvers’ outstanding asks are withdrawn — ApprovalCancelled and ApprovalStatusChanged for each — then ApprovalApproversReplaced fires with stage null and the approvers added and removed.
- It never resolves the round, and no authorization gate runs: check who may reassign the round first.
It refuses an empty list (noReplacementApprovers), a closed or overdue round (ClosedApprovalRequestException), an approver who already decided (alreadyDecidedRound), an excluded approver (excludedApprover), a list under which the round could never resolve (moreRequiredThanNamed, unreachableThreshold) or would settle at once (replacementSettlesRound), a within() round of another subject (foreignSubject), a subject without a round (noRound), and as(), because(), weight() or an expiry (settingsOnSubjectCall). Nothing changes when it is refused.
Re-checking that a round can still reach its threshold
Approvers leave: their account is deleted or soft-deleted, they become excluded, or they stop passing the decision gate. A round named on them may never resolve. recheckThreshold() checks the subject’s latest open round — or the within() round — and returns a ThresholdVerdict, or null when no round is open (a round past its expiry is lapsed as expired on the way):
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
use RoundlyConsulting\Approvals\Facades\Approvals;
$verdict = Approvals::for($invoice)->recheckThreshold(); // check only
$verdict = Approvals::for($invoice)->recheckThreshold(ApprovalStatus::Cancelled); // void if unreachable
$verdict = Approvals::for($invoice)->recheckThreshold(ApprovalStatus::Rejected); // fail if unreachable
if ($verdict !== null && ! $verdict->reachable) {
$verdict->unavailable; // list<NamedApprover> who can no longer decide
$verdict->stage; // the first stage that can no longer clear (staged rounds)
$verdict->closedAs; // Cancelled / Rejected when it closed the round, else null
}An outstanding named approver — one without a decision in force — can still decide when:
- its model still exists — a missing row, a soft-deleted model or a morph type that no longer resolves to a model means it can’t;
- it isn’t excluded, by the round’s list or the resolver;
- the decision gate (authorization.enabled) is off, or it passes the gate, or it holds a delegation in force to a delegate who exists, isn’t excluded and passes the gate.
Reachable means the decisions in force plus what those approvers could still add can approve the round: one approval for Any, the required headcount and no rejection for Unanimous, the threshold by weight for Quorum and Weighted. A round or stage that names nobody lets anyone decide, so it always reads as reachable. A staged round is reachable when every stage still to settle can clear; one that continues past a rejected stage is reachable when one stage already cleared or one still can.
With $closeAs — Cancelled or Rejected — an unreachable round closes the way the engine closes one (asks retired, ApprovalRequestResolved, ApprovalStatusChanged), then ApprovalThresholdUnreachable fires with the verdict. A reachable round stays open whatever $closeAs says, and any other outcome throws InvalidStatusTransitionException (notAnUnreachableOutcome). Run it from a scheduled command, or after you deactivate a user.
Concurrency
Every decision resolves where it lands, then writes inside a transaction that first takes the round’s row lock. replaceFlatApprovers(), voidIfChanged() and a closing recheckThreshold() take the same lock and decide under it, so:
- a decision that resolved its target before a replacement and reaches the lock after it re-reads the round’s approvers there — an approver the replacement dropped, or its delegate, gets UnauthorizedApprovalException and nothing is written;
- a decision that reaches the lock after a void or a closing re-check finds the round closed and gets ClosedApprovalRequestException;
- with on_change set to refuse or void, a decision re-reads the subject’s fingerprint under the lock — a subject that changed after the decision’s first check is refused there, without voiding, since a void inside the refused write would roll back with it;
- a void or re-check that reaches the lock after a decision resolved the round finds it closed and does nothing — no event fires. Two writers closing one round close it once.
Events fire after the transaction commits.
Facade, DI and actions
replaceFlatApprovers(), voidIfChanged() and recheckThreshold() take only for() and within(); as(), because(), weight() and an expiry throw InvalidApprovalRequestException (settingsOnSubjectCall). The same calls through the injected manager and the raw actions:
use RoundlyConsulting\Approvals\Actions\RecheckApprovalThresholdAction;
use RoundlyConsulting\Approvals\Actions\ReplaceRequestApproversAction;
use RoundlyConsulting\Approvals\Actions\VoidChangedApprovalRequestsAction;
use RoundlyConsulting\Approvals\ApprovalsManager;
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
use RoundlyConsulting\Approvals\Facades\Approvals;
// Facade
Approvals::for($invoice)->recheckThreshold(ApprovalStatus::Cancelled);
// Injected manager
app(ApprovalsManager::class)->for($invoice)->voidIfChanged();
// The raw actions
app(ReplaceRequestApproversAction::class)->execute($invoice, [$manager], $round);
app(VoidChangedApprovalRequestsAction::class)->execute($invoice, $round);
app(RecheckApprovalThresholdAction::class)->execute($invoice, $round, ApprovalStatus::Rejected);Approvals::fake() records them as Replace (with stage null), Void and Recheck — see Testing.
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.