yohang/calendr
Calendr is an object-oriented PHP calendar library for creating, managing, and rendering calendars and events. It supports recurring events, multiple calendars, and flexible output via renderers, making it easy to integrate scheduling into PHP applications.
Calendr is a framework-agnostic domain library: it gives you period objects (Day, Week, Month, Year, Range), an event abstraction, and a provider mechanism to pull events into those periods. It does not ship Eloquent models, controllers, or HTML rendering — you own persistence and presentation.
composer require yohang/calendr
There is no service provider to publish. Register it once as a singleton so providers are configured in one place:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Calendr\Calendar::class, function ($app) {
$calendar = new \Calendr\Calendar();
$calendar->getEventManager()->addProvider($app->make(\App\Calendar\EloquentEventProvider::class));
return $calendar;
});
First use case — a month grid with events:
use Calendr\Calendar;
$calendar = app(Calendar::class);
$month = $calendar->getMonth(2025, 12); // Period\Month
foreach ($month as $week) { // Month iterates weeks
foreach ($week as $day) { // Week iterates days
$inMonth = $month->contains($day); // false for leading/trailing days
$events = $calendar->getEvents($day); // aggregated from all providers
}
}
Look first at Calendr\Calendar (period factory + event aggregation), Calendr\Period\PeriodInterface (getBegin, getEnd, contains, getNext, getPrevious, isCurrent, format), and Calendr\Event\Provider\ProviderInterface — that interface is the only integration point most applications need.
Provider-backed events are the core integration. Implement one provider per data source; Calendr handles the rest.
use Calendr\Event\Event;
use Calendr\Event\Provider\ProviderInterface;
use Calendr\Period\PeriodInterface;
final class EloquentEventProvider implements ProviderInterface
{
public function getEvents(PeriodInterface $period, array $options = []): iterable
{
$rows = Booking::query()
->where('starts_at', '<', $period->getEnd())
->where('ends_at', '>', $period->getBegin()) // overlap, not BETWEEN
->when($options['user_id'] ?? null, fn ($q, $id) => $q->where('user_id', $id))
->get();
foreach ($rows as $booking) {
yield new Event(
'booking-'.$booking->id,
$booking->starts_at->toDateTime(), // convert UTC -> display TZ here
$booking->ends_at->toDateTime(),
);
}
}
}
Use the options array as your filter channel. $calendar->getEvents($month, ['user_id' => $id]) forwards the second argument to every provider, so tenant scoping, visibility flags, and "include cancelled" toggles travel with the period instead of leaking into provider constructors.
Fetch once per period, then index. Calling getEvents($day) inside the week/day loop causes one provider round-trip per day. Prefer:
$byDate = [];
foreach ($calendar->getEvents($month, ['user_id' => $userId]) as $event) {
$cursor = clone $event->getStart();
while ($cursor <= $event->getEnd()) { // multi-day events
$byDate[$cursor->format('Y-m-d')][] = $event;
$cursor->modify('+1 day');
}
}
// in the view: $byDate[$day->format('Y-m-d')] ?? []
Navigation without date math. $month->getPrevious() / getNext() return same-type periods, and $month->isCurrent() drives the "today" highlight. For agenda views use getRange(new DateTime($from), new DateTime($to)) and render a flat list rather than a grid.
Custom events instead of mapping. If your Booking model can implement EventInterface (getUid, getStart, getEnd, containsDate, containsPeriod), return it directly and skip DTO creation; keep presentation concerns in Blade, not in the event object.
Rendering lives in your app. A Blade partial that loops weeks → days → $byDate[$day->format('Y-m-d')] is the idiomatic approach; there is no built-in HTML renderer to configure. For week grids, use $day->format('j'), $day->isCurrent(), and $month->contains($day) to dim adjacent-month cells.
Testing. Periods are pure functions of the DateTime you pass in (apart from isCurrent()), so unit-test providers with fixed dates and new Range(...) rather than mocking the clock.
Month yields whole weeks, so the first and last week contain days from neighbouring months. Always gate with $month->contains($day) before counting events or attaching links.getWeek($year, $number) expects the ISO year, not the calendar year — week 1 of 2026 can start in December 2025. Derive keys with $date->format('o-W'), never Y-W, or your late-December views will show the wrong week.contains semantics. Events should be selected with start < periodEnd AND end > periodBegin; BETWEEN silently drops multi-day bookings that straddle the boundary.DateTime is mutable. Calendr's API hands you DateTime instances; calling modify() on a value you also passed into a period can shift the period's bounds. clone before mutating, or normalize to DateTimeImmutable in your provider and convert at the edge.2025-12-01 23:30 belongs to Dec 2 in Europe/Paris. Convert to the display timezone inside the provider, before constructing events, and let periods be built in that same timezone.Cache::remember("cal:{$userId}:{$month->format('Y-m')}", ...)) if the same month is rendered in multiple widgets on one page.user_id silently returns everything..ics export need a separate package (eluceo/ical, spatie/icalendar-generator) — Calendr is the view-model/aggregation layer only.ProviderInterface implementations for new data sources, EventInterface on domain models, and the calendar's period factory if you need non-Gregorian or fiscal periods.How can I help you explore Laravel packages today?