›
byrcsc/laravel-approval · 1.x
Delegate, reassign, or resync approvers without erasing the original assignment.
Three operations move an assignment, and they answer three different questions.
| Operation | Question | Who performs it |
|---|---|---|
delegate() | "I am on holiday" | The approver |
reassign() | "They left the company" | An administrator |
resync() | "The whole department reorganized" | An operator |
All three leave the original approver on the record where it matters: who was asked is part of the history.
An approver hands their own assignment on the waiting stage to somebody else:
Approval::delegate($request, $financeUser, $deputy, 'On leave this week');The assignment is the same assignment; only who may fill it has changed. The
approver_* columns keep naming the original approver and delegated_to_*
names the delegate, so the stage's approval arithmetic is unaffected, one
holder in, one holder out.
The request appears in exactly one inbox afterwards: the delegate's.
Ask before rendering a control:
$request->canBeDelegatedBy($approver); // bool
$request->canBeDelegatedBy($approver, $deputy); // including the target
Approval::delegationEligibility($request, $approver, $deputy);The eligibility object carries eligible, reason, the stage, its progress,
and the assignment. The refusal reasons are:
| Reason | Meaning |
|---|---|
RequestClosed | The request is no longer pending |
NoActiveStage | Nothing is waiting |
NotAnApprover | This actor holds no assignment on the active stage |
AssignmentDelegated | Their assignment already belongs to a delegate |
DecisionAlreadyRecorded | They have already decided |
ApproverMissing | The approver record no longer exists |
CannotDelegateToSelf | The delegate is the approver |
DelegateAlreadyAssigned | The delegate already holds an assignment on the stage |
DelegateMissing | The delegate record does not exist |
DelegateAlreadyAssigned is what prevents a handover from quietly reducing the
number of distinct people a stage is waiting on.
ApprovalDelegated fires with the assignment, the approver, and the delegate.
Administrative reassignment rewrites which approver an assignment belongs to:
Approval::reassign($request, $assignment, $newApprover, $administrator, 'Left the company');Unlike delegation, this works on any undecided stage, not only the active one, a stage three approver can be replaced while stage one is still running.
It requires an attributable actor. ApproverReassigned fires with the
assignment, the previous holder, the new holder, and the operator. The previous
holder is null when the record that held the assignment has itself been
deleted, which is the common reason to reach for this at all.
Approver snapshots go stale on purpose, that is what makes an audit trail mean something. Resync is the sanctioned way to overrule that, for when a reorganization has left half a department's pending requests pointing at people who no longer approve anything.
$changes = Approval::resync($request, $operator);
$changes = Approval::resync($request, $operator, prune: true);Or from the console, which is the intended path:
php artisan approval:resync 01JBK… --operator=42
php artisan approval:resync 01JBK… --operator="App\Models\Staff:7" --prune--operator is required. Shell history is not attribution.
Pruning is off by default. Adding the people who should now be asked is almost always safe; removing the ones who should not is a judgement about whether their opinion still counts, and that belongs to whoever runs the command.
[
1 => ['added' => ['App\Models\User:9'], 'removed' => []],
2 => ['added' => [], 'removed' => ['App\Models\User:4']],
]Keyed by stage sequence. An empty array means the request already matched its workflow.
ApproversResynced fires with the changes, the operator, and the assignment
rows that were created. A resync can satisfy the active stage's threshold after
pruning holders who had not responded. In that case, the stage completes
immediately.
None of the three operations may leave a stage worse off than it found it. A stage is stranded when the assignments whose holders still exist are fewer than the approvals it requires: nobody can finish it.
The invariant is judged on whether a mutation increased the deficit, not on whether the result is perfect. Refusing every mutation that leaves a stage stranded would also refuse the ones repairing it, two dead assignments cannot be replaced one at a time if the first replacement is rolled back for not being the second as well.
A mutation that worsens the deficit raises
InvalidWorkflowException::livingHoldersBelowRequired(). A mutation that
repairs a stage clears its stranded_at mark.
Find stranded stages before somebody reports them:
php artisan approval:doctorStageStranded fires when the escalation sweep first detects one. See
SLAs and escalation.