SLA policies and business hours
Define first response and resolution targets per priority, count only business hours, and flag and escalate breached tickets.
An SLA policy sets two targets for each ticket priority: how long until the first agent response, and how long until the ticket is resolved. Tickets get their deadlines from a policy when they are created, and the service:check-sla command flags tickets that miss them.
The SlaPolicy model
Odden\Service\Models\SlaPolicy has these attributes. Targets are in minutes; the defaults are the database column defaults.
| Attribute | Default | Notes |
|---|---|---|
name, description |
||
is_default |
false |
The policy attached to new tickets that don't specify one. |
is_active |
true |
Stored, but not checked by the package (see below). |
urgent_first_response_minutes / urgent_resolution_minutes |
60 / 240 | |
high_first_response_minutes / high_resolution_minutes |
120 / 480 | |
medium_first_response_minutes / medium_resolution_minutes |
240 / 1440 | |
low_first_response_minutes / low_resolution_minutes |
480 / 2880 | |
only_business_hours |
false |
Count only time inside business hours. |
business_hours_start / business_hours_end |
09:00 / 17:00 |
HH:MM, in the policy's timezone. |
business_days |
null |
ISO weekday numbers (Monday is 1, Sunday is 7). null means Monday to Friday. |
holidays |
null |
Dates as Y-m-d strings. |
timezone |
UTC |
SlaPolicy::defaultPreset() returns an attribute array for a default policy with the targets above, so you can create one in a seeder or migration:
use Odden\Service\Models\SlaPolicy;
SlaPolicy::create(SlaPolicy::defaultPreset());Methods:
| Method | Returns |
|---|---|
getFirstResponseMinutesFor(TicketPriority $priority): int |
The first response target for a priority. |
getResolutionMinutesFor(TicketPriority $priority): int |
The resolution target for a priority. |
calculateDueTime(CarbonInterface $from, int $minutes): CarbonInterface |
The deadline $minutes after $from, see below. |
tickets() |
HasMany of tickets using the policy. |
How tickets get deadlines
When a ticket is created:
- If
sla_policy_idis empty, the first policy withis_default = trueis used.is_activeis not considered, so an inactive default policy is still applied. Keep only one default. - If the ticket has a policy and no
first_response_due_at, bothfirst_response_due_atandresolution_due_atare set withcalculateDueTime(now(), ...)using the targets for the ticket's priority.
To use a different policy for a ticket, pass it to CreateTicketAction (slaPolicy: $policy) or set sla_policy_id when calling Ticket::create(). You can also set the two *_due_at columns yourself; they are then left alone.
Deadlines are calculated once. Changing a ticket's priority or policy later does not recalculate them.
Business hours
Without only_business_hours, calculateDueTime() adds the minutes to the start time.
With it, the clock only runs during business hours in the policy's timezone. Time before opening is moved to opening time, time after closing rolls to the next day, and days that are not in business_days or are listed in holidays are skipped. The result is converted to your app.timezone.
use Carbon\Carbon;
use Odden\Service\Enums\TicketPriority;
use Odden\Service\Models\SlaPolicy;
$policy = SlaPolicy::create([
'name' => 'Business hours (New York)',
'is_default' => true,
'only_business_hours' => true,
'business_hours_start' => '09:00',
'business_hours_end' => '17:00',
'business_days' => [1, 2, 3, 4, 5], // ISO weekdays: Monday = 1
'holidays' => ['2026-12-25'],
'timezone' => 'America/New_York',
'high_first_response_minutes' => 120,
'high_resolution_minutes' => 960,
]);
$due = $policy->calculateDueTime(
Carbon::parse('2026-12-24 16:00', 'America/New_York'),
$policy->getFirstResponseMinutesFor(TicketPriority::High),
);
// 60 minutes on Thursday the 24th, then Friday is a holiday and the weekend is skipped:
// due Monday 2026-12-28 at 10:00 New York time (15:00 UTC).Any targets you don't set use the column defaults listed above.
Recording responses and resolution
The breach flags are also set as the ticket progresses, not only by the scheduled check:
- The first public agent reply sets
first_responded_atand setsis_sla_response_breachedtotrueif it came afterfirst_response_due_at, orfalseotherwise. Ticket::resolve()setsresolved_atand setsis_sla_resolution_breachedthe same way againstresolution_due_at.
isFirstResponseBreached() and isResolutionBreached() on the ticket also report a breach that has happened but has not been flagged yet.
Checking for breaches
service:check-sla runs CheckSlaBreachesAction::execute(). Run it often, for example every five minutes, from your scheduler (see Installation).
php artisan service:check-slaThe action finds tickets that are not Resolved or Closed and:
- have no
first_responded_at, afirst_response_due_atin the past, andis_sla_response_breachedstillfalse; or - have no
resolved_at, aresolution_due_atin the past, andis_sla_resolution_breachedstillfalse.
For each one it sets the breach flag (quietly, without model events), so each breach is handled once. Then:
- If the ticket has an owner with a
notify()method, the owner receivesSlaBreachAlertNotificationby email. The breach type isfirst_responseorresolution. - Otherwise the ticket is escalated: its priority goes up one level (
LowtoMedium,MediumtoHigh,HightoUrgent;UrgentstaysUrgent), and an internalSystemnote is added saying the ticket breached its first response (or resolution) SLA while unassigned and naming the new priority.
Escalating the priority does not change the ticket's SLA deadlines.
The action returns the counts, and the command prints them:
use Odden\Service\Actions\CheckSlaBreachesAction;
$counts = app(CheckSlaBreachesAction::class)->execute();
// ['response_breaches' => 2, 'resolution_breaches' => 0]The breach alert email
SlaBreachAlertNotification has the subject [URGENT SLA BREACH] Ticket #{number}: {subject} and lists the breach type, priority, subject, and assigned agent. Its button links to the hard-coded path url('/admin/tickets/{id}/edit'), which is the ticket edit page of the Filament admin when its panel is served at /admin. If your agents view tickets at a different URL, the link won't match your app.