byrcsc/laravel-approval · 1.x
Delegation and reassignment.
Hand an assignment to a deputy, administratively rewrite who an assignment belongs to, and rebuild a live request's approvers from the current workflow.
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.
Delegation
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.
Delegation eligibility
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.
Reassignment
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.
Resynchronizing a live request
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.
What resync touches
- Only undecided stages. A stage that has been decided is history.
- Never an assignment that has decided or been delegated. Both are records of something a person did, and a resync is a correction to the future, not to the past.
- A request stage whose workflow stage was deleted is left alone. There is nothing current to resync it against, so the snapshot runs as it always would have.
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.
The return value
[
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 that satisfies the active stage's threshold —
because pruning removed the holders who had not responded — completes that
stage immediately.
Stranded stages
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.