Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Chronos Laravel Package

andydefer/laravel-chronos

Laravel Chronos is an advanced scheduling engine for Laravel. Manage recurring availability, appointments and blocked periods, prevent double bookings, enforce 17 business validation rules and automatically find the next free slot.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish.

composer require andydefer/laravel-chronos
php artisan vendor:publish --tag=chronos-config
php artisan vendor:publish --tag=chronos-migrations
php artisan migrate

Requirements are PHP 8.1+ and Laravel 10/11/12. Publish the config and read it before you write any code — slot granularity, buffer between appointments, and the absolute minimum duration used for loop protection all live there.

2. Know the four layers before touching anything.

Layer Namespace What it's for
Contracts Contracts\Services\*Interface What you inject. Never resolve concrete classes.
Records Records\AvailabilityRecord, ScheduleRecord, ImpedimentRecord Write-side DTOs, built with ::from([...]).
Models Models\Schedule, Models\Impediment, … Read-side / Eloquent results you pass around.
Value Objects ValueObjects\DateTimeZuluVO, SlotVO Time handling. Everything is UTC ("Zulu").

Open Contracts\Services\SlotServiceInterface first — it is the smallest interface and tells you the most about how the engine thinks (slot generation, availability checks, blocked-period lookups). Then AvailabilityServiceInterface.

3. First use case — the four calls that define the package.

$availabilityService = app(AvailabilityServiceInterface::class);
$scheduleService     = app(ScheduleServiceInterface::class);
$slotService         = app(SlotServiceInterface::class);

// a) Define when the resource CAN be booked (recurring rule, not slots)
$availability = $availabilityService->for($doctor)->create(AvailabilityRecord::from([
    'name'          => 'Consultations',
    'days'          => ['monday', 'wednesday', 'friday'],
    'daily_start'   => '09:00:00',
    'daily_end'     => '17:00:00',
    'validity_start'=> '2024-01-01T00:00:00Z',
    'validity_end'  => '2024-12-31T23:59:59Z',
]));

// b) Ask the engine what's actually free
$slot = $slotService->findNextSlot($doctor, DateTimeZuluVO::now(), 30);

// c) Book it
$scheduleService->for($doctor)->create(ScheduleRecord::from([
    'availability_id' => $availability->id,
    'title'           => 'Consultation patient',
    'start_datetime'  => $slot->getStart()->getValue(),
    'end_datetime'    => $slot->getEnd()->getValue(),
    'status'          => ScheduleStatus::BOOKED,
]));

// d) Block time out
$impedimentService->for($doctor)->create(ImpedimentRecord::from([...]));

If you internalise only one thing: Availability is a recurrence rule, Schedule is a booking, Impediment is a block. Most misuse of this package comes from treating Availability as a list of slots.


Implementation Patterns

Always use ->for() — treat it as your tenant boundary

The fluent ->for($owner) scope is the package's answer to multi-tenant isolation ("does this schedule belong to this doctor?"). Make it non-optional in your own code:

// Wrap it so nobody on the team can forget it
abstract class ScopedChronosAction
{
    protected function schedules(Model $owner): ScheduleServiceInterface
    {
        return app(ScheduleServiceInterface::class)->for($owner);
    }
}

Then cancel(), complete(), findByDate(), searchByTitle() etc. all operate within the owner's data set. If your app ever grows a second "owner" concept (clinic, room, team), you get isolation for free.

The booking flow: search → re-validate → create

findNextSlot() is advisory, not a lock. The README's own example does the right thing — copy that pattern:

public function book(Doctor $doctor, Patient $patient, int $duration): Schedule
{
    $slot = $this->slotService->findNextSlot($doctor, DateTimeZuluVO::now(), $duration);
    if (! $slot) {
        throw new NoAvailabilityException();
    }

    // Re-check: microseconds matter, and two requests can race
    if (! $this->slotService->isSlotAvailable($doctor, $slot->getStart(), $slot->getEnd())) {
        throw new SlotTakenException();
    }

    // Schedule requires availability_id — resolve the governing rule
    $availability = $this->availabilityService
        ->for($doctor)
        ->findActiveAtDate($doctor, $slot->getStart())
        ->first();

    return $this->scheduleService->for($doctor)->create(ScheduleRecord::from([
        'availability_id' => $availability->id,
        'title'           => "Consultation - {$patient->name}",
        'start_datetime'  => $slot->getStart()->getValue(),
        'end_datetime'    => $slot->getEnd()->getValue(),
        'status'          => ScheduleStatus::BOOKED,
    ]));
}

Wrap the whole method in DB::transaction() and add your own unique index or lockForUpdate() on the target range. The package validates business rules; it is not a concurrency primitive.

Multiple Availabilities beat one clever Availability

The engine expects you to model real-world shape as several rows. Morning/afternoon splits, phone-duty hours on a shorter validity window, seasonal patterns — all get their own AvailabilityRecord. Don't try to encode "not on Tuesdays in July" into a single row.

Guard every status transition

ScheduleStatus transitions are not free; the service exposes canBeCancelled() and canBeCompleted(). Check first, then call the scoped mutation. This gives you a clean domain error you can map to a 422 instead of an exception surfacing from deep inside the package:

if (! $this->scheduleService->canBeCancelled($schedule)) {
    throw new InvalidScheduleTransition($schedule->status);
}
$this->scheduleService->for($doctor)->cancel($schedule->id);

Analyse impediment impact before creating it

The killer feature for day-to-day work. When a doctor requests leave, you don't want to silently strand a waiting room:

$blocked    = $impedimentService->getBlockedSchedules($impediment);
$fully      = $impedimentService->getFullyBlockedSchedules($impediment);
$partially  = $impedimentService->getPartiallyBlockedSchedules($impediment);

if ($blocked->isNotEmpty()) {
    // notify patients, offer slots via findNextSlot(), or refuse the block
}

The fully vs partially split is exactly what you need for "can the patient still come in late?" logic. Impediment creation does not auto-cancel affected schedules — that is your decision to make.

Keep Chronos types at the service boundary

Don't leak DateTimeZuluVO into controllers, FormRequests, or API resources. Convert at the edge:

// In the controller: accept anything, normalise to Zulu once
$start = DateTimeZuluVO::from($request->input('start_datetime'));

Testing

Bind fake implementations of the three interfaces in a test service provider — that's why the package ships interfaces at all. For real behaviour tests, Carbon::setTestNow() plus a fixed availability makes findNextSlot() deterministic. Seed one doctor, one availability, one impediment, assert the holes.


Gotchas and Tips

Everything is UTC — deliberately. DateTimeZuluVO and the Z suffix on validity_start/validity_end are not decoration. daily_start / daily_end are wall-clock strings, so a practice in Europe/Paris will see its 09:00 window shift relative to local time across DST. Test explicitly around a DST boundary before you promise a clinic that "09:00 means 09:00".

The docs are in French. The API is English (findActiveAtDate, getFullyBlockedSchedules) but every explanation, error message, and the "17 validation rules" list is French. For a non-francophone team this is a real onboarding cost — budget a half-day for someone to translate the contracts into your internal wiki. The interface docblocks are the most reliable source once the README trail ends (it literally cuts off mid-example).

Scoping is inconsistent between methods. findNextSlot($doctor, ...) takes the owner as an argument, while findByDate() / cancel() / findActive() rely on the prior ->for($owner) call. Some methods take the owner again even inside a scope (findActiveAtDate($doctor, $startDate)). Read each interface signature rather than guessing; a TypeError here is easy to mistake for a missing scope.

Re-validate right before writing. isSlotAvailable() immediately before create() is not paranoia — the gap between search and write is where double-bookings live.

Loop protection relies on config. The README advertises protection against infinite loops via an "absolute minimum duration." That means a request for a 0-minute or absurdly short slot may throw or be silently clamped. Validate duration at the FormRequest level and read the config key so you can return a helpful message.

Buffers create phantom gaps. If a buffer between appointments is configured, findNextSlot() will refuse slots that look free on a calendar. When a user says "but 14:00 is empty!", check the buffer config before debugging your code.

int $limit = null is implicit-nullable. Several search methods use this signature, which is deprecated in PHP 8.4. Expect deprecation notices on modern PHP; pass an explicit null and keep an eye on upstream.

Impediments don't cascade. Creating an impediment over existing bookings succeeds silently. Always run the impact analysis first and decide: refuse, reschedule, or notify. Same for cancelling an availability — schedules are not orphaned for you.

Published migrations are yours now. Review the table names and column types before migrate on an existing schema, especially if you already have an appointments or schedules table. Rename via the published file, not afterwards.

Extension point: bind your own implementations. The *Interface bindings are the seam. If you need audit logging, tenant injection, or a custom slot algorithm, subclass the concrete service and re-bind the interface in a ChronosServiceProvider registered after the package's. Don't fork the package — the interface set (AvailabilityServiceInterface, ScheduleServiceInterface, SlotServiceInterface, ImpedimentServiceInterface) is stable enough to decorate.

Collections, not arrays. The package returns typed collections (SlotVOCollection, BlockedPeriodCollection). Use isEmpty() not count() === 0, and check the collection class before assuming array methods work.

Zero stars, zero dependents. Treat it as an early-stage package: pin the version in composer.json, read the source of the services you rely on most (it's small), and write integration tests around the booking flow so an update can't silently change semantics.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core