Skip to content

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:

php
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:

  1. If sla_policy_id is empty, the active policy with is_default = true is used (SlaPolicy::defaultPolicy()). Inactive policies are skipped, so an inactive default is never applied. Only one policy is the default: saving a policy with is_default on turns it off on the others. Should several still be marked default (saved before this rule), the most recently saved one wins.
  2. The SLA clock starts: sla_started_at is set to the creation time.
  3. If the ticket has a policy, each of first_response_due_at and resolution_due_at that 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_at is 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_at is added to sla_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_at and 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) clears resolved_at and closed_at and restarts the clock: sla_started_at becomes now, the pause credit is reset, is_sla_resolution_breached is reset, and resolution_due_at is 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.

php
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_at and sets is_sla_response_breached to true if it came after first_response_due_at, or false otherwise.
  • Resolving the ticket (Ticket::resolve(), or any status change to Resolved) sets resolved_at and sets is_sla_resolution_breached if the clock had run out against resolution_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).

bash
php artisan service:check-sla

The action finds tickets that are not Resolved or Closed and:

  • have no first_responded_at, a first_response_due_at in the past, and is_sla_response_breached still false; or
  • have no resolved_at, a resolution_due_at in the past, and is_sla_resolution_breached still false.

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 receives SlaBreachAlertNotification by email. The breach type is first_response or resolution.
  • Otherwise the ticket is escalated: its priority goes up one level (Low to Medium, Medium to High, High to Urgent; Urgent stays Urgent), and an internal System note 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:

php
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.