byrcsc/laravel-approval · 1.x
Recording decisions.
Approve, reject, or return a request for revision, build a decision from a form, and understand how stage progress and rejection policy advance a request.
There are three decisions, and they all live on the facade:
use ByRcsc\LaravelApproval\Facades\Approval;
Approval::approve($request, $financeUser, 'Within budget');
Approval::reject($request, $financeUser, 'Over budget for this quarter');
Approval::returnForRevision($request, $financeUser, 'Attach the supplier quote');Approval's comment is optional. Rejecting and returning both require one: "no" without a reason is not a decision anybody can act on.
Each takes an optional trailing $metadata array, recorded on the audit action
as-is.
Building a decision elsewhere
One controller action serving several buttons should not branch on the verdict at the call site. Build the decision as a value and dispatch it:
use ByRcsc\LaravelApproval\Data\ApprovalDecision;
use ByRcsc\LaravelApproval\Enums\Verdict;
$decision = new ApprovalDecision(
actor: $request->user(),
verdict: Verdict::from($validated['verdict']),
comment: $validated['comment'] ?? null,
);
Approval::decide($approvalRequest, $decision);ApprovalDecision enforces the comment rule in its constructor, throwing
CommentRequiredException. That happens while the controller still has a form
to put the error back on, rather than deep inside the engine.
The named constructors mirror the facade:
ApprovalDecision::approve($actor, $comment = null, $metadata = []);
ApprovalDecision::reject($actor, $comment, $metadata = []);
ApprovalDecision::return($actor, $comment, $metadata = []);What a decision checks
Every decision passes the same gate, which asks four questions in order:
- Is the request still pending?
- Is a stage actually waiting?
- Does this actor hold an assignment on it?
- Are they allowed to fill it?
A refusal becomes an exception:
| Reason | Exception |
|---|---|
RequestClosed | AlreadyDecidedException::requestIsClosed() |
NoActiveStage | AlreadyDecidedException::noActiveStage() |
NotAnApprover | UnauthorizedApproverException::notAnApprover() |
AssignmentDelegated | UnauthorizedApproverException::assignmentWasDelegated() |
DecisionAlreadyRecorded | AlreadyDecidedException::decisionAlreadyRecorded() |
SelfApproval | SelfApprovalException::forRequester() |
The same calculation, without the throw, is
Approval::eligibility(). There is one implementation, so a
disabled button and a refused decision can never disagree.
Approving
An approval is recorded against the actor's assignment and fires
StageApprovalRecorded with the running count. When the stage's threshold is
met:
- the stage moves to
approvedandStageCompletedfires, carrying the next stage or null; - the next applicable stage activates, which starts its SLA deadline when it has one;
- when there is no next stage,
ApprovalCompletedfires and the request is closed — applying its draft, or becomingconflictedif the record moved.
Reading progress mid-stage:
$progress = $request->stageProgress();
$progress?->assignedApprovers;
$progress?->decisionsRecorded;
$progress?->approvalsReceived;
$progress?->approvalsRequired;
$progress?->remainingApprovals;
$progress?->rejectionsReceived;
$progress?->status;
$progress?->isSatisfied();Approvals are counted per assignment holder, not per action. Delegation moves an assignment's holder rather than adding one, so the arithmetic holds.
Rejecting
Rejection ends the whole request, not just the stage. When it takes effect depends on the stage's rejection policy:
approval_impossible, the default, ends the request once the remaining approvers can no longer reach the threshold;any_rejection_vetoesends it on the first rejection.
On a 2-of-5 stage under the default policy, three people may say no and the
stage continues — the two who have not answered can still supply both
approvals. The fourth rejection leaves one possible approval against a
threshold of two, and the request is rejected.
The rule behind that is possibleApprovals < approvalsRequired, where
possibleApprovals counts the approvals already received plus the holders who
have not answered yet. Both are on
stageProgress(), so a view can show
how much room is left before the stage is lost.
Stages that never ran are marked skipped rather than rejected.
ApprovalRejected fires with the stage, the actor, and the reason. The actor
is null when a deadline rejected it rather than a person.
Returning for revision
A return sends the request back to its requester without ending it as a
failure. The request state becomes returned, and the requester revises and
resubmits — creating a new linked request from a fresh workflow snapshot. See
returns and resubmission.
Use return when the proposal is fixable and reject when it is not. The two
states are distinguishable for reporting, and only returned reads as "waiting
on the requester".
Concurrency
Decisions take a row lock on the request and are serialized against one
another. Two approvers pressing approve at the same moment on a 1-of-2 stage
produce one completion and one AlreadyDecidedException, not two completions.
The same holds at the final stage: the draft is applied exactly once.
Deciding many requests
Approval::approveMany(), rejectMany(), and decideMany() decide a
collection with per-request transactions, so one failure does not roll back the
rest. See bulk decisions.