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

OpeningHours::fake() records every write — facade, injected manager, builder save() and the owner trait alike — without touching the database, firing events or materializing intervals. Each write still runs the real manager’s checks against the database as it is — payload validation, calendar key, persisted owner, limits, foreign ids, expectedRevision, and CalendarNotFoundException for exception writes without a calendar — so a test fails where production would. Reads still hit the database, so a faked write is not visible to the next one:

use RoundlyConsulting\OpeningHours\DataTransferObjects\CalendarData;
use RoundlyConsulting\OpeningHours\DataTransferObjects\ExceptionData;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;

$fake = OpeningHours::fake();

$this->post("/clinics/{$clinic->id}/holidays", ['date' => '2026-12-24']);

$fake->assertExceptionAdded($clinic, callback: fn (ExceptionData $data) => $data->label === 'Holiday');
$fake->assertNothingSynced();

// The owner trait and the builder are recorded too:
$clinic->setOpeningHours(['week' => ['monday' => ['09:00-17:00']]], 'pickup');
OpeningHours::delete($clinic, force: true);

$fake->assertSynced($clinic, 'pickup', fn (CalendarData $data) => count($data->schedules) === 1);
$fake->assertDeleted($clinic, force: true);
OpeningHours::assertNothingRefreshed();          // every assertion also works statically
AssertionPasses whenOpposite
assertSynced($owner, ?$calendar, ?fn (CalendarData): bool)A sync matched — the facade, a builder save(), setOpeningHours() or editOpeningHours()->save().assertNothingSynced()
assertExceptionAdded($owner, ?$calendar, ?fn (ExceptionData): bool)exceptions()->add(), closed() or open() matched.assertNoExceptionsAdded()
assertExceptionRemoved($owner, ?int $ruleId, ?$calendar)exceptions()->remove() matched.assertNoExceptionsRemoved()
assertDeleted($owner, ?$calendar, ?bool $force)delete() matched — including the owner-delete cascade, recorded as force: true.assertNothingDeleted()
assertRefreshed($owner, ?$calendar)refresh() matched.assertNothingRefreshed()
—No write of any kind was recorded.assertNothingWritten()

A null calendar means the default one; owners match by Model::is(). Every assertion also works statically (OpeningHours::assertSynced($clinic)), and writes() returns the recorded RecordedWrite list — operation, owner, calendar and payload — for custom checks. Under the fake, sync() returns an in-memory OpeningHours, exceptions()->add(), closed() and open() return an unsaved ExceptionRule, and remove() and delete() return true. Where the real manager has nothing to do, the fake answers the same and records nothing: delete() and exceptions()->remove() return false, and refresh() of a missing calendar is a no-op. Revision hooks for direct row edits aren’t faked.

Against real rows

“Now” comes from Laravel’s Date facade, so freeze time with CarbonImmutable::setTestNow() or travelTo() and assert through the public API:

use Carbon\CarbonImmutable;
use RoundlyConsulting\OpeningHours\OpeningHours;

afterEach(fn () => CarbonImmutable::setTestNow());

it('reports the clinic as open on Monday morning', function (): void {
    CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-09-28 10:00', 'Europe/Bratislava'));

    $clinic = Clinic::factory()->create();
    $clinic->setOpeningHours([
        'timezone' => 'Europe/Bratislava',
        'week' => ['monday' => ['08:00-12:00', '13:00-17:00']],
    ]);

    expect($clinic->openingHours()->isOpen())->toBeTrue()
        ->and($clinic->openingHours()->nextClose()?->format('H:i'))->toBe('12:00');
});

it('previews overnight hours without a database', function (): void {
    $preview = OpeningHours::make(['week' => ['saturday' => ['22:00-03:00']]], 'Europe/Bratislava');

    expect($preview->isOpenAt(CarbonImmutable::parse('2026-10-04 01:00', 'Europe/Bratislava')))->toBeTrue();
});

Without the fake, seed hours with OpeningHours::sync() or setOpeningHours() — it runs the same validation as production — or with the model factories in RoundlyConsulting\OpeningHours\Database\Factories: CalendarFactory (forOwner(), timezone(), key()), ScheduleFactory (base(), seasonal()), ScheduleRangeFactory (on(), between()), ExceptionRuleFactory (closed(), on(), yearly()), ExceptionRangeFactory (between()) and IntervalFactory.

OpeningHours::make() needs no database at all — ideal for unit tests of rendering or slot logic. The package’s own suite runs against SQLite, PostgreSQL and MySQL.

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.