NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Workflows for Laravel

Business time and escalations

Calendars

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

NameSource
weekdaysBuilt-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 => …).
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:

"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" } ] }
]
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

By donating, you agree to our donation terms.

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.