Decisions & statuses
Every decision is an Approval row between an actor and an approvable. It carries an explicit status — pending, approved, rejected, cancelled or expired — plus an optional reason, an optional expiry, a weight and the time it was decided.
A decision’s lifecycle
use RoundlyConsulting\Approvals\Facades\Approvals;
// 1. Ask for a decision — records a pending row.
Approvals::for($contract)->as($legal)->ask();
Approvals::for($contract)->hasPending(); // true
// 2. The same actor decides — the pending row becomes approved.
Approvals::for($contract)->as($legal)->because('Clauses reviewed')->approve();
Approvals::for($contract)->as($legal)->isApproved(); // true
// 3. Changed their mind — the approval is withdrawn (cancelled), only the rejection counts.
Approvals::for($contract)->as($legal)->because('New revision uploaded')->reject();
// 4. Or withdraw the live decision without replacing it.
Approvals::for($contract)->as($legal)->cancel(); // status: cancelledOne live decision per slot
Each actor holds one live decision per slot — a standalone decision on the approvable, or its decision on one request (or one stage of a staged request). Deciding again changes that decision rather than adding a second one: approving twice is a no-op, and rejecting after approving withdraws the approval (its status becomes cancelled) so only the rejection counts — and vice versa.
A database unique index guards the slot, so two concurrent approvals by the same actor are counted once. A new request, or the next stage, is a fresh slot: approving an earlier request never blocks you from approving the next.
Legal transitions
Statuses move through a guarded state machine. A decided approval can still be cancelled — withdrawn, or superseded when the actor changes their mind — and an approval lapses once its expiry passes. Cancelled and expired are terminal:
| From | Can move to |
|---|---|
pending | approved, rejected, cancelled, expired |
approved | cancelled (withdrawn or superseded), expired (past its expiry) |
rejected | cancelled — withdrawn or superseded |
cancelled / expired | nothing — terminal |
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
ApprovalStatus::Pending->canTransitionTo(ApprovalStatus::Approved); // true
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Cancelled); // true — withdrawn or superseded
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Expired); // true — lapses once its expiry passes
ApprovalStatus::Rejected->canTransitionTo(ApprovalStatus::Cancelled); // true — withdrawn or superseded
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Rejected); // false — a change of mind writes a new decision
ApprovalStatus::Cancelled->canTransitionTo(ApprovalStatus::Approved); // false — terminalThe transition methods on the Approval model — approve(), reject(), cancel() and markExpired() — validate every move and throw InvalidStatusTransitionException on an illegal one.
What each call does
- approve() — records an approval in the actor’s slot. A pending ask is decided in place; a held rejection is superseded and a fresh approval written. Approving again returns the same approval and fires no events.
- reject() — the mirror image: a pending ask is decided in place, and a held approval is withdrawn so only the rejection counts. A rejection has no expiry, so setting one before reject() throws InvalidApprovalRequestException.
- ask() — records a pending decision for the actor, addressed to that model itself, never to whoever it stands in for, with because() as the ask’s reason and the expiry as its reply-by deadline. When the actor already holds a live decision in the slot, that decision is returned unchanged. weight() is refused — the answer carries its own.
- cancel() / cancelApproval() — withdraws the actor’s live decision (pending, approved or rejected), or one it made as a delegate while that delegation is still in force, in the same round a decision would land in — on a staged request, only in the open stage. because() becomes the withdrawal’s reason; weight() or an expiry is refused. Returns the decision, or null when there is nothing to withdraw.
- toggle() / toggleApproval() — the simple thumbs-up, below.
On a closed request they throw ClosedApprovalRequestException — only repeating an approval or rejection the actor already holds there stays a no-op (see Multi-approver requests). The trait methods on GivesApprovals — approve(), reject(), cancelApproval() and toggleApproval() — are shorthand for the same facade calls. A setting a call has no place for is refused rather than dropped — the full list is under Fluent builders.
Reasons
An answer to an ask decides the ask’s row in place and records only its own reason: approve(), reject() or toggling on without because() store none, and one with because() stores that one. The ask’s reason stays on the ApprovalRequested event and on the row until the ask is answered. A withdrawal is not a decision of its own, so it keeps the decision’s reason when it gives none — cancel() or toggling off without because(), a decision superseded when the actor changes their mind, and an ask retired when its round closes. Repeating a decision the actor already holds changes nothing, reason included.
The Approval model’s transition methods follow the same rule: approve($reason) and reject($reason) write $reason as given (null clears the ask’s), cancel($reason) replaces it only when one is given, and markExpired() leaves it alone. Before 1.2.3 an answer without a reason kept the ask’s, so an approval could read “Please review…”.
Reading a decision
$approval = Approvals::for($deployment)->as($user)->because('LGTM')->approve();
$approval->status; // ApprovalStatus::Approved
$approval->reason; // 'LGTM'
$approval->decided_at; // CarbonImmutable
$approval->expires_at; // ?CarbonImmutable
$approval->weight; // int — 1 unless weighted
$approval->actor; // the model whose decision this is
$approval->approvable; // the model decided on
$approval->approvalRequest; // ?ApprovalRequest it counts toward
$approval->stage; // ?ApprovalRequestStage, for staged requests
$approval->wasDelegated(); // bool — decided by a delegateThe simple toggle
The one-click on/off form for likes, endorsements and single-reviewer flows:
Approvals::for($deployment)->as($user)->toggle(); // true — approved (status: approved)
Approvals::for($deployment)->as($user)->toggle(); // false — withdrawn (status: cancelled, soft-deleted)
// Pinned to one round, with a reason — the approval's when it turns on, the withdrawal's when it turns off
Approvals::for($release)->as($lead)->within($request)->because('LGTM')->toggle();
$user->toggleApproval($deployment); // the same through the GivesApprovals traitToggling on is an approve() — the authorization gate, the request’s named approvers, delegation and the round it lands in all apply, along with because(), weight() and the expiry, and a rejection you hold is superseded. Toggling off withdraws your live approval and soft-deletes it, firing ApprovalCancelled as cancel() does, with because() as the withdrawal’s reason; weight() and the expiry have nothing to apply to. within() pins the toggle to one round: a round of another subject throws InvalidApprovalRequestException, and a closed one ClosedApprovalRequestException — even while a later round is open. Both directions fire ApprovalToggled and ApprovalStatusChanged — if you listen to ApprovalCancelled and ApprovalToggled alike, make sure a toggle-off isn’t handled twice.
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.