Business days and hours
Deadlines and SLAs count in open time, not calendar time. addOpenDays() returns the same local time on the n-th open date after the start’s date (“2 business days”: Fri 15:00 → Tue 15:00); addOpenTime() returns the instant a CarbonInterval of open time later (“4 business hours”). Both skip closed weekdays, holidays, exceptions and dynamic exceptions, and both return a CarbonImmutable in the calendar’s output timezone:
use Carbon\CarbonImmutable;
use Carbon\CarbonInterval;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;
OpeningHours::sync($office, [
'timezone' => 'Europe/Bratislava',
'week' => array_fill_keys(['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], ['09:00-17:00']),
'exceptions' => [['date' => '2026-11-09']], // a holiday Monday
]);
OpeningHours::sync($office, ['timezone' => 'Europe/Bratislava', 'week' => ['saturday' => ['10:00-14:00']]], 'weekend');
$friday = CarbonImmutable::parse('2026-11-06 10:00', 'Europe/Bratislava');
// "N business days": the same local time on the n-th open date after the start's date
OpeningHours::addOpenDays($office, $friday, 2); // Wed 2026-11-11 10:00 — the holiday skipped
OpeningHours::addOpenDays($office, $friday, 1, 'weekend'); // Sat 2026-11-07 10:00
// "N business hours": the instant that much open time later
OpeningHours::addOpenTime($office, $friday->setTime(16, 0), CarbonInterval::hours(2)); // Tue 2026-11-10 10:00
OpeningHours::addOpenTime($office, $friday, CarbonInterval::hours(5), 'weekend'); // Sat 2026-11-14 11:00Every entry point
The facade and the injected manager take the owner and an optional calendar key; the query object, the owner trait and the actions run the same code — a host rebind of AddOpenDaysAction or AddOpenTimeAction is honoured behind all of them:
use Carbon\CarbonInterval;
use RoundlyConsulting\OpeningHours\Actions\AddOpenTimeAction;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;
use RoundlyConsulting\OpeningHours\OpeningHoursManager;
// The query object and the owner trait — shorthand over the same manager:
OpeningHours::for($office)->addOpenDays($friday, 2); // Wed 10:00
$office->addOpenDays($friday, 2); // Wed 10:00
$office->addOpenTime($friday, CarbonInterval::hours(8)); // Tue 10:00
$office->addOpenTime($friday, CarbonInterval::hour(), 'weekend'); // Sat 11:00
// The injected manager and the raw action:
app(OpeningHoursManager::class)->addOpenDays($office, $friday, 2); // Wed 10:00
app(AddOpenTimeAction::class)->execute(OpeningHours::for($office), $friday, CarbonInterval::hours(8)); // Tue 10:00How they count
- An open date is one any range starts on — the same rule as isOpenOnDate() — with exceptions and dynamic exceptions applied, so a holiday is skipped and an exception that opens a closed day counts.
- addOpenDays() counts open dates strictly after the start’s local date and keeps the start’s wall-clock time, even when the office is closed at that time.
- addOpenTime() consumes only open time. Periods are half-open, so a start exactly at a close counts as closed (Mon 17:00 + 30 min → Tue 09:30), and an overnight run is consumed across midnight.
- addOpenTime() counts whole seconds of the interval — a fraction of a second is dropped.
- The start may be any DateTimeInterface and is read in the calendar’s timezone: Fri 23:30 in New York is already Saturday in Bratislava.
- Zero or a negative amount returns the start unchanged, in the output timezone.
use Carbon\CarbonImmutable;
use Carbon\CarbonInterval;
use RoundlyConsulting\OpeningHours\OpeningHours;
$hours = OpeningHours::make([
'week' => array_fill_keys(['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], ['09:00-17:00']),
], 'UTC');
$hours->addOpenTime(CarbonImmutable::parse('2026-11-06 16:00', 'UTC'), CarbonInterval::hours(2)); // Mon 10:00
$hours->addOpenTime(CarbonImmutable::parse('2026-11-09 17:00', 'UTC'), CarbonInterval::minutes(30)); // Tue 09:30 — 17:00 is already closed
$hours->addOpenDays(CarbonImmutable::parse('2026-11-07 10:00', 'UTC'), 2); // Tue 10:00 — Saturday's time kept
$hours->addOpenDays(CarbonImmutable::parse('2026-11-07 10:00', 'UTC'), 0); // Sat 10:00 — zero or less returns the startClock changes
addOpenDays() keeps the start’s wall-clock time on the target date. A time that occurs twice takes its first occurrence, as everywhere in the engine, but a time inside a spring-forward gap moves forward by the gap (02:30 → 03:30, as PHP resolves it) rather than to the transition instant — a deadline keeps its distance from the start. addOpenTime() counts real elapsed open time, so a 25-hour fall-back day holds 25 open hours:
use Carbon\CarbonImmutable;
use Carbon\CarbonInterval;
use RoundlyConsulting\OpeningHours\OpeningHours;
// Europe/Bratislava springs forward on Sunday 2026-03-29 and falls back on Sunday 2026-10-25
$sundays = OpeningHours::make(['week' => ['sunday' => ['00:00-24:00']]], 'Europe/Bratislava');
$sundays->addOpenDays(CarbonImmutable::parse('2026-03-22 02:30', 'Europe/Bratislava'), 1);
// 2026-03-29 03:30 — 02:30 does not exist that day, so it moves forward by the gap
$sundays->addOpenDays(CarbonImmutable::parse('2026-10-18 02:30', 'Europe/Bratislava'), 1);
// 2026-10-25 02:30 +02:00 — 02:30 happens twice; the first occurrence
$sundays->addOpenTime(CarbonImmutable::parse('2026-10-25 00:00', 'Europe/Bratislava'), CarbonInterval::hours(24));
// 2026-10-25 23:00 — the day holds 25 real open hoursThe search window
Neither method searches forever. The answer must fall within opening-hours.search_days local days of the start’s date — the whole span, not the gap between open days. A calendar that is never open, an owner without a calendar, or a span longer than the window throws OpenTimeNotFoundException, carrying searchDays:
use RoundlyConsulting\OpeningHours\Exceptions\OpenTimeNotFoundException;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;
try {
$due = OpeningHours::addOpenDays($office, now(), 2);
} catch (OpenTimeNotFoundException $e) {
// The calendar has not enough open time within 366 days of the start (opening-hours.search_days).
// $e->searchDays === 366
}Under OpeningHours::fake() both methods still answer from the database and record nothing — they are reads.
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 cryptoBy 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.