Browse documentationOpen

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:

  1. Is the request still pending?
  2. Is a stage actually waiting?
  3. Does this actor hold an assignment on it?
  4. Are they allowed to fill it?

A refusal becomes an exception:

ReasonException
RequestClosedAlreadyDecidedException::requestIsClosed()
NoActiveStageAlreadyDecidedException::noActiveStage()
NotAnApproverUnauthorizedApproverException::notAnApprover()
AssignmentDelegatedUnauthorizedApproverException::assignmentWasDelegated()
DecisionAlreadyRecordedAlreadyDecidedException::decisionAlreadyRecorded()
SelfApprovalSelfApprovalException::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 approved and StageCompleted fires, 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, 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.

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_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.

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.