›
byrcsc/laravel-approval · 1.x
Delegate, reassign, or resync approvers without erasing the original assignment.
Each operation that moves an assignment answers a different question.
| Operation | Question | Who performs it |
|---|---|---|
delegate() | "Someone else should decide this one" | The approver |
grantDelegation() | "I am on holiday from Monday to Friday" | The approver |
reassign() | "They left the company" | An administrator |
resync() | "The whole department reorganized" | An operator |
Every one of them keeps the original approver on the record where it matters, because 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');Delegation keeps the same assignment and changes only who may fill it. The
approver_* columns keep naming the original approver, and delegated_to_*
names the delegate. The stage's approval count does not change, because one
holder leaves and one holder arrives.
Afterwards, the request appears in exactly one inbox, the delegate's.
Check eligibility before you render a delegate 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 stops a delegation from reducing the number of
distinct people a stage is waiting on.
ApprovalDelegated fires with the assignment, the approver, and the delegate.
A standing delegation covers a window of time instead of one request. While the window is open, every undecided assignment the approver holds on an active stage goes to the delegate:
$delegation = Approval::grantDelegation(
$financeUser,
$deputy,
now()->next('Monday'),
now()->next('Monday')->addWeek(),
'Annual leave',
);A null start opens the window now. A null end leaves it open until you revoke it. To end a window early, revoke it:
Approval::revokeDelegation($delegation);Each routed assignment works the same way as a hand delegation. delegated_to_*
names the delegate, approver_* keeps naming the approver, and the request
appears only in the delegate's inbox. The assignment also records the
delegation that routed it in delegation_id, so the package can return it to
the approver when the window closes.
Assignments move at these points:
approval:delegations runs.Only approval:delegations needs a scheduler. A window that opens or closes
later moves nothing until approval:delegations runs, so schedule it when you
grant windows in advance or with an end date:
Schedule::command('approval:delegations')->everyFifteenMinutes();The command is safe to run as often as you like. Approval::applyDelegations()
does the same work and returns how many assignments moved.
When a window closes, each undecided assignment it routed goes back to the approver. A decision the delegate already recorded stays as it was recorded.
Routing leaves an assignment with its approver in these cases:
Delegations do not chain. A delegate who has their own window open keeps what was routed to them.
An approver has at most one delegation covering any moment, so the delegate an
assignment goes to never depends on row order. grantDelegation() throws
DelegationException when:
| Check | Message |
|---|---|
| The delegations migration has run | Names the commands to publish and run it |
| The delegate is not the approver | cannot delegate their approval to themselves |
| Both models exist | no longer exists |
| The end is after the start | A delegation cannot end before it starts. |
| No current delegation overlaps the window | already has a delegation covering part of this window |
To change a window, revoke it and grant a new one. Revoking a delegation twice also throws.
List an approver's delegations that have not ended or been revoked, earliest first:
Approval::delegationsFrom($financeUser)->get();Revoking stamps revoked_at on the ApprovalDelegation row instead of
deleting it, so the window that routed an assignment stays readable.
Each move is a line in the request's audit trail. Routing writes a delegated
action attributed to the approver, with the delegation's comment. Handing back
writes a delegation_ended action with no actor.
Routing on grant or from the scheduled command fires
ApprovalDelegated. Routing on stage activation fires no event of its own,
because ApprovalSubmitted or StageCompleted already tells the delegate.
Handing back fires DelegationEnded 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, reassignment works on any undecided stage, not only the active one. You can replace a stage three approver while stage one is still running.
Reassignment requires an attributable actor. ApproverReassigned fires with
the assignment, the previous holder, the new holder, and the operator. The
previous holder is null when someone has deleted the record that held the
assignment. A deleted approver record is the most common reason to reassign.
Approver snapshots go stale on purpose, because a fixed snapshot is what makes an audit trail mean something. Resync is the supported way to update a snapshot. Use it 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);The console command 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, because shell history does not attribute the change
to a person.
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 judgement belongs to whoever runs the command.
[
1 => ['added' => ['App\Models\User:9'], 'removed' => []],
2 => ['added' => [], 'removed' => ['App\Models\User:4']],
]The array keys are stage sequence numbers. 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 delegate(), reassign(), or resync() may leave a stage worse off
than before. A stage is stranded when the assignments whose holders still
exist are fewer than the approvals the stage requires. Nobody can finish a
stranded stage.
The package judges each mutation by whether it increased the deficit, not by whether the result is perfect. Refusing every mutation that leaves a stage stranded would also refuse the mutations that repair it. For example, you replace two dead assignments one at a time. A strict rule would roll back the first replacement because the second assignment is still dead.
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.