---
title: "Business time and escalations — Workflows for Laravel | Roundly"
description: "Weekday and opening-hours calendars, business-day and business-hour arithmetic across DST, and escalation levels that reassign, notify or start workflows."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/business-time-and-escalations
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Workflows for Laravel](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)

# Business time and escalations

## Calendars

A calendar implements Contracts\\BusinessCalendar: timezone(), isBusinessDay($date), isOpenAt($instant), nextOpen($instant), nextClose($instant).

| Name | Source |
| --- | --- |
| `weekdays` | Built-in: whole days on business\_time.weekdays (default Mon–Fri), in the version timezone. |
| `opening_hours:<name>` | Opening-hours bridge: the owner’s opening-hours schedule <name> (holidays and exceptions included). |
| … | Anything you register: Workflows::calendars()->register('support', fn (?Model $owner, string $timezone): BusinessCalendar => …). |

```php
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Workflows\Contracts\BusinessCalendar;

Workflows::calendars()->register('support', fn (?Model $owner, string $timezone): BusinessCalendar => new SupportCalendar($owner, $timezone));

Workflows::calendars()->names();                       // ['weekdays', 'support', …]
Workflows::calendars()->find('support', $team, 'Europe/Bratislava');
```

- The owner of an opening\_hours: calendar is the instance owner, or for app-wide definitions the model returned by business\_time.calendar\_owner (an invokable class).
- A graph’s settings.calendar is the default; a duration’s calendar overrides it.
- A missing calendar is unknown\_calendar at publish and fails the step calendar\_missing at run time.

## Business arithmetic

- Business days: the same local wall-clock time on the n-th business date strictly after the start date. Fri 15:00 + 2 → Tue 15:00. Sat 10:00 + 2 → Tue 10:00. A time that doesn’t exist (DST gap) moves to the next valid instant; a time that occurs twice (DST fall-back) takes the first occurrence.
- Business hours/minutes consume open time — closed periods are skipped, and an instant exactly at a close counts as closed.
- A calendar that implements Contracts\\CountsBusinessTime (addBusinessDays(), addBusinessTime()) does the arithmetic itself — the opening\_hours: calendars hand it to opening-hours. Any other calendar (weekdays, your own) is counted by the package’s clock, walking isOpenAt() / nextOpen() / nextClose().
- Searches are bounded: the package’s clock by business\_time.max\_search\_days (366), opening-hours by opening-hours.search\_days (366). A calendar without enough open time in the window fails the step calendar\_never\_open.
- One edge difference: opening-hours bounds the whole span by its search\_days, an always-open calendar included, while the package’s clock never stops on a calendar that never closes. With the defaults (366 both) the results agree.
- Calendar units (P1M from Jan 31) follow Carbon in the version timezone (→ Feb 28/29).

## Escalations

Steps that wait (wait, approval, host types with supportsEscalations()) take an escalations block:

```jsonc
"escalations": [
  { "after": { "amount": 2, "unit": "business_days" },
    "actions": [
      { "type": "reassign", "to": [ { "kind": "field", "field": "subject.approver_manager" } ] },
      { "type": "notify", "notification": "purchase.escalated", "recipients": [ { "kind": "field", "field": "subject.requester" } ] } ] },
  { "after": "P1D",
    "actions": [ { "type": "start_workflow", "definition": "purchase-escalation-board" } ] }
]
```

```php
use RoundlyConsulting\Workflows\Graph\Build\{After, Escalate, Party};

Workflows::graph()
    ->approval('approve', approvers: [Party::field('subject.approver')], expiresIn: After::businessDays(5))
    ->escalate(After::businessDays(2), Escalate::reassign(Party::field('subject.approver_manager')),
        Escalate::notify('purchase.escalated', Party::field('subject.requester')))
    ->escalate(After::days(1), Escalate::startWorkflow('purchase-escalation-board'));
```

- Up to escalations.max\_levels (5) levels, up to 5 actions each. notify (notifications bridge) — {notification, recipients, data?}; reassign — {to: parties}, through the step type’s reassign() (approval implements it; refused at publish on a type without it); start\_workflow — {definition, input?}; the child gets the subject unless it is subject-less, and a child the start-rate limit refuses counts as a failed action (child\_not\_started): the escalation is retried with backoff, not marked fired.
- Timing: level 1 is due at the visit’s first started\_at + after; level n + 1 at level n’s fired\_at + its after. Only the next pending level exists as a row. Automatic retries keep the chain. A manual retry of a step whose chain was cut off when it failed starts it again at level 1, timed from the retried attempt’s start.
- Firing (claimed by workflows:wake, run by FireEscalationJob in the owner context): if the step is between two attempts, the escalation stays pending and comes due again a minute after that attempt is scheduled; if the step is no longer running, waiting or queued, the escalation is cancelled. Otherwise each action runs once (idempotent per level and action), the row is fired, the next level is created and EscalationTriggered fires.
- Failure: an action failure is retried with the retry policy; when exhausted the escalation is failed and EscalationFailed fires. The step and the instance keep running: an escalation never fails the work it escalates.
- Pending escalations are cancelled when the step finishes (completed, failed or cancelled), in the same transaction. Dry runs list each level with its computed due time.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)
