›
byrcsc/laravel-approval · 1.x
Approve, reject, or return the active stage of an approval request.
The facade has a method for each decision:
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');The approval comment is optional. Rejecting and returning both require one, because "no" without a reason is not a decision anybody can act on.
Each method takes an optional trailing $metadata array. The package records
it 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 goes through the same check, which asks these 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.
The engine records an approval against the actor's assignment and fires
StageApprovalRecorded with the running count. When the stage meets its
threshold, these steps follow:
approved, and StageCompleted fires with the next stage
or null.ApprovalCompleted fires and the engine closes
the request. The engine applies the draft, or marks the request conflicted
if the record changed.To read progress during a stage, call stageProgress():
$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 count stays correct.
Rejection ends the whole request rather than only the stage. The stage's rejection policy decides when a rejection takes effect:
approval_impossible, the default, ends the request once the remaining
approvers can no longer reach the threshold.any_rejection_vetoes ends the request 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 engine rejects the request.
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 many more rejections the stage can take before it fails.
The engine marks stages that never ran as skipped rather than rejected.
ApprovalRejected fires with the stage, the actor, and the reason. The actor
is null when a deadline caused the rejection rather than a person.
A return sends the request back to its requester without ending it as a
failure. The request state becomes returned. The requester then revises and
resubmits, and resubmission creates 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".
Each decision locks the request's row, so decisions on one request run one at
a time. Two approvers who press 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 engine applies the draft 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.