Paid add-on. Service is licensed separately from the open-source packages. See pricing
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 panel requires at least 1); 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. Only one policy is the default. |
is_active |
true |
Inactive policies are never picked as the default for new tickets. |
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. Anything else in the list is ignored; the panel only accepts Y-m-d. |
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. A negative amount counts as zero. |
calculateDueTimeInSeconds(CarbonInterface $from, int $seconds): CarbonInterface |
The same, in seconds. |
runningSecondsBetween(CarbonInterface $from, CarbonInterface $to): int |
How many seconds the clock runs between two moments: all of them, or only those in business hours. |
SlaPolicy::defaultPolicy(): ?SlaPolicy |
The active default policy new tickets get. |
tickets() |
HasMany of tickets using the policy. |
How tickets get deadlines
When a ticket is created:
- If
sla_policy_idis empty, the active policy withis_default = trueis used (SlaPolicy::defaultPolicy()). Inactive policies are skipped, so an inactive default is never applied. Only one policy is the default: saving a policy withis_defaulton turns it off on the others. Should several still be marked default (saved before this rule), the most recently saved one wins. - The SLA clock starts:
sla_started_atis set to the creation time. - If the ticket has a policy, each of
first_response_due_atandresolution_due_atthat wasn't given is calculated from 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; the ones you set are kept.
The SLA clock
A ticket's deadlines come from three stored values:
| Column | Meaning |
|---|---|
sla_started_at |
The clock base: when the ticket was created, or when it was last reopened. |
sla_paused_seconds |
The pause credit: how long the clock would have run while the ticket waited on the customer. Under a business-hours policy only business time counts. |
sla_paused_at |
When the current wait on the customer began, or null when the clock is running. |
Each deadline is the clock base plus the target for the ticket's priority plus the pause credit, counted with calculateDueTimeInSeconds() (so in business time under a business-hours policy). recalculateSlaDueDates() computes them that way, and the ticket recalculates its unmet deadlines whenever its priority or sla_policy_id changes, including the priority bump of a breach escalation. A deadline whose target is already met is left alone: first_response_due_at once first_responded_at is set, and resolution_due_at once resolved_at is set. Removing the policy (sla_policy_id set to null) clears the unmet deadlines. A save that sets a *_due_at column itself keeps that value.
The clock moves with the status. Every way of changing the status (the actions, the panel's edit form and board, a customer reply) goes through Ticket::statusChanges():
- Waiting on Customer pauses the clock:
sla_paused_atis set. Every public agent reply on a ticket that is not resolved or closed moves it there. - Leaving Waiting on Customer (for any status, including Resolved and Closed) resumes it: the time the clock would have run since
sla_paused_atis added tosla_paused_seconds, and the unmet deadlines move out by the same amount. A ticket whose resolution deadline had already passed when the wait began earns no credit: it was late before it waited. - Resolved sets
resolved_atand records a resolution breach if the clock had run out, not counting time spent waiting on the customer. - Closed sets
closed_at. - Reopening (from Resolved or Closed to any open status, by a customer reply,
reopen(), the edit form or the board) clearsresolved_atandclosed_atand restarts the clock:sla_started_atbecomes now, the pause credit is reset,is_sla_resolution_breachedis reset, andresolution_due_atis recalculated from now (or cleared when there is no policy).
Tickets created before sla_started_at existed use their creation time as the base. Pause credit they earned before sla_paused_seconds existed was added to their deadlines at the time but is not recorded, so a later priority change recalculates without it.
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. - Resolving the ticket (
Ticket::resolve(), or any status change to Resolved) setsresolved_atand setsis_sla_resolution_breachedif the clock had run out againstresolution_due_at. Time spent waiting on the customer doesn't count, so resolving straight from Waiting on Customer is not a breach unless the deadline had already passed when the wait began.
isFirstResponseBreached() and isResolutionBreached() on the ticket also report a breach that has happened but has not been flagged yet. A ticket waiting on the customer only shows as breached if its deadline had passed before the wait began.
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 recalculates the ticket's unmet deadlines for the new priority from the clock base and pause credit (see The SLA clock). A breach flag that is already set stays set. The owners of the tickets found are loaded in one query.
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 odden-service.admin_ticket_url config value (default /admin/tickets/{id}/edit, the ticket edit page of the Filament admin when its panel is served at /admin), where {id} is the ticket ID and relative paths are resolved against the app URL. For a dynamic link, call SlaBreachAlertNotification::resolveUrlUsing(fn (Ticket $ticket): string => ...) from a service provider; the callback wins over the config value.