byrcsc/laravel-approval · 1.x
SLAs and escalation.
Give stages deadlines and reminders, choose what happens when a deadline passes, and write a custom escalation strategy.
A stage with sla_minutes gets a deadline, measured from the moment it becomes
active — the moment it starts waiting on its approvers, not the moment the
request was submitted.
$workflow->stages()->create([
'sequence' => 1,
'name' => 'Finance',
'required_approvals' => 1,
'sla_minutes' => 1440,
'remind_every_minutes' => 240,
'escalation_strategy' => EscalationAction::Escalate,
'escalation_config' => ['resolver' => App\Org\ManagerOf::class],
]);Deadlines are recorded whether or not anything acts on them.
Nothing escalates until the sweep runs. Schedule it:
Schedule::command('approval:escalate')->everyFifteenMinutes();
The interval is a floor on how late "late" is noticed, not a correctness knob. The sweep is idempotent, so running it every minute and running it every hour produce the same escalations at different resolutions.
Escalation actions
Each stage names one action, and the approval.escalation.strategies config
map says which class runs it.
| Action | Shipped strategy | Behavior |
|---|---|---|
record_only | RecordOnlyStrategy | Records the overdue state and emits the event |
escalate | EscalateStrategy | Adds further approvers to the stage |
auto_approve | AutoDecideStrategy | Approves the stage and advances the request |
auto_reject | AutoDecideStrategy | Rejects the request |
record_only is the default, and it is empty on purpose. Marking the stage,
firing StageOverdue, and writing the audit line all happen before any
strategy runs, so by the time it is called everything record-only means has
already happened.
It promises no delivery. Turning on notifications — or listening for the event — is what turns the recorded fact into a message. Deciding an approval automatically because it is late is a policy each organization chooses, not one a package should assume.
Adding approvers on a deadline
escalate resolves further approvers, typically the approver's manager or a
duty officer:
'escalation_config' => [
'resolver' => App\Org\ManagerOf::class,
'resolver_config' => ['levels' => 1],
],The resolver is an ordinary ApproverResolver.
People already represented on the stage are skipped, and the new assignments
are recorded with the escalated source.
One rule needs care. A stage with a null required_approvals requires all of
its assignments, so adding one would raise the number of approvals needed and
make an already-late stage harder to finish. To avoid that, a null rule is
pinned to the assignment count the stage had before the addition — the added
approvers act as alternatives to those who have not responded, not as extra
requirements.
Set required_approvals inside escalation_config to pin a different number:
'escalation_config' => [
'resolver' => App\Org\DutyOfficer::class,
'required_approvals' => 1,
],Existing assignments are left in place: an approver who is only slow may still respond, and removing their assignment would discard work in progress.
ApproversEscalated fires after the assignments exist, so recipient
calculation can only ever see the new holders.
Deciding automatically
auto_approve completes the stage and advances the request exactly as an
approver's decision would, applying the draft with the same stale check if it
was the last stage. auto_reject closes the request, which the requester may
then revise and resubmit.
Both record their action with no actor, because there was none. "The
deadline passed and the stage was approved" is a different fact from "an
approver approved it", and the history has to distinguish them. metadata
carries automatic: true.
Override the recorded comment through the config:
'escalation_config' => ['comment' => 'Auto-approved under the 24-hour SLA.'],Reminders
A stage with remind_every_minutes keeps emitting after it first goes overdue:
StageOverduefires once, when the deadline first passes, before the strategy acts, so a listener sees the state that went overdue.StageOverdueReminderfires at the configured cadence afterwards.
Reminders never rerun the strategy. escalated_at is what makes each strategy
run exactly once; last_reminded_at paces the reminders. Each reminder writes
a reminded audit action.
Custom strategies
Implement the contract and point a case at it:
use ByRcsc\LaravelApproval\Contracts\EscalationStrategy;
use ByRcsc\LaravelApproval\Enums\EscalationAction;
final class NotifyDutyManager implements EscalationStrategy
{
public function handle(
ApprovalRequest $request,
ApprovalRequestStage $stage,
EscalationAction $action,
array $config,
): void {
// ...
}
}// config/approval.php
'escalation' => [
'strategies' => [
'record_only' => NotifyDutyManager::class,
],
],Strategies are resolved from the container and run inside the transaction covering that stage. Throw, and nothing about the stage is committed — the escalation mark included — so the next run tries again. That is the right behavior for the transient failures that dominate here.
$action names which case sent it there, so one class can serve several.
Configuration is validated at boot: an unknown case, a missing class, or a
class that does not implement the contract raises
InvalidConfigurationException.
The sweep is not all-or-nothing
Each stage gets its own transaction, so one misconfigured stage cannot stop the other four hundred from being handled. The command reports what happened and exits non-zero if anything threw:
php artisan approval:escalate[
'escalated' => ['12', '31'], // stage ids
'reminded' => ['8'],
'stranded' => ['44'],
'failed' => ['19' => 'Stage 3 escalates by bringing somebody in, but its escalation_config names no resolver to find them.'],
]Approval::escalateOverdue() returns the same array, for hosts driving it from
their own job.
Finding overdue work
$requests = Approval::overdue()->withProgress()->get();overdue() returns pending requests whose active stage is past its deadline,
independent of whether the escalation policy has run. A stage remains overdue
until it is decided.
Stranded detection
The sweep also scans active stages whose living assignment holders can no
longer meet the required approvals, marks them, and fires StageStranded.
These are not overdue — they are unfinishable, and no deadline will fix them.
See delegation and reassignment.