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.
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.
->for() — treat it as your tenant boundaryThe 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.
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.
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.
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);
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.
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'));
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.
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.
How can I help you explore Laravel packages today?