›
byrcsc/laravel-approval · 1.x
Approve, reject, or return the active stage of an approval 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.
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 = []);Every decision passes the same gate, which asks four questions in order:
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.
An approval is recorded against the actor's assignment and fires
StageApprovalRecorded with the running count. When the stage's threshold is
met:
approved and StageCompleted fires, carrying the next
stage or null;ApprovalCompleted fires and the request is
closed, applying its draft, or becoming conflicted if 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.
Rejection ends the whole request rather than only 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_vetoes ends 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.
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".
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.
Approval::approveMany(), rejectMany(), and decideMany() decide a
collection with per-request transactions, so one failure does not roll back the
rest. See bulk decisions.