Browse documentationOpen

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.

ActionShipped strategyBehavior
record_onlyRecordOnlyStrategyRecords the overdue state and emits the event
escalateEscalateStrategyAdds further approvers to the stage
auto_approveAutoDecideStrategyApproves the stage and advances the request
auto_rejectAutoDecideStrategyRejects 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:

  • StageOverdue fires once, when the deadline first passes, before the strategy acts, so a listener sees the state that went overdue.
  • StageOverdueReminder fires 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.