tattali/calendar-bundle
A Symfony bundle for rendering responsive calendars in Twig, with month, week, and day views. Feed events through flexible providers, customize templates and styling, and add schedulers or event calendars to any Symfony app with minimal configuration.
tattali/calendar-bundle wires FullCalendar into a Symfony app: the bundle exposes a JSON endpoint that FullCalendar calls on navigation/refetch, and your code fills that response with events from a dispatched event. It is a Symfony bundle (auto-registered via Flex), not a Laravel package — in a Laravel app you would re-implement the endpoint yourself, but the CalendarEvent dispatch pattern and the request/response contract are worth copying.
Minimal wiring:
composer require tattali/calendar-bundle
# config/packages/tattali_calendar.yaml
tattali_calendar:
calendar:
load_route: calendar.load # default route name serving the JSON feed
First use case — a read-only month view backed by Doctrine:
events at the route generated from load_route (the bundle ships a default template/JS you can copy).Tattali\CalendarBundle\Event\CalendarEvent. This is the only place you need to look first — the whole package is essentially "dispatch an event, add events to it".$calendar->addEvent(...) for each row.final class CalendarListener
{
public function __construct(private EventRepository $events) {}
public function onCalendarSetData(CalendarEvent $calendar): void
{
$start = $calendar->getStart(); // DateTimeImmutable, from the request
$end = $calendar->getEnd();
foreach ($this->events->findBetween($start, $end) as $event) {
$calendar->addEvent($event->getStart(), $event->getEnd(), [
'id' => $event->getId(),
'title' => $event->getTitle(),
'allDay' => $event->isAllDay(),
'url' => '/events/'.$event->getId(),
'backgroundColor' => $event->getColor(),
]);
}
}
}
Register it with #[AsEventListener] (autoconfigured) or kernel.event_listener in services.yaml. That's the entire happy path.
v8.3.0 note — this is a breaking release. It requires PHP 8.4+ and Symfony 8, and the upgrade guide (docs/upgrade-to-83.md) lists one migration step per change — work through it in order. The changes that touch your code:
private ETag revalidation (i.e. the old ?start=…&end=… GET client from before 8.2.1 works, and revalidation is per-user). The bundled JS still posts; both work now.2026-01-01, epoch timestamps, now, +1 month) now fail instead of falling back.CalendarEvent getters and the request dates passed to listeners are DateTimeImmutable; Event accepts any DateTimeInterface in its constructor and in setStart()/setEnd().Event's constructor now routes through the setters (so subclass normalisation applies), and the request parser and serializer are final and wired via interface aliases.Listener-per-concern. Rather than one god-listener, split sources: DoctrineCalendarListener (persisted events), HolidayCalendarListener (computed), DeadlineCalendarListener. Each just adds to the same CalendarEvent. Ordering matters only for visual overlap; use setPriority() on listeners if you need deterministic output.
Service-layer behind the listener. Keep the listener thin — it should only translate domain objects into FullCalendar arrays. Put range queries, recurrence expansion, and permission filtering in a dedicated service so you can unit-test them without booting the kernel or faking a request.
Recurring events. The bundle does not expand RRULEs. Expand them yourself inside the requested [start, end) window before adding, otherwise an infinite series will be returned truncated by FullCalendar's own view and look wrong at boundaries. Use the window, not now, as the anchor.
Editable calendars. Two patterns work well:
eventClick / select callbacks POSTing to your own CRUD controller, then calendar.refetchEvents(). Simplest and most robust.eventDrop → PATCH endpoint → return the updated event JSON so the UI does not flicker.Either way, keep the write endpoints separate from load_route; the bundle only serves reads.
Use GET for reads now that it is supported. As of 8.3.0 the feed accepts GET with private ETag revalidation, so a plain events: '/calendar/load' (query-string params) is a supported configuration again. GET keeps the read path out of CSRF/body-parsing territory and lets the browser do conditional revalidation for free. Keep POST only if you need large filter payloads — and then remember the parser still reads from the body.
Progressive enhancement / filtering. FullCalendar forwards extra params (?room=3) on each fetch. With GET restored, query params are a natural fit again for the built-in client; the bundle's POST path still reads from the body, so if you post, append filters there. Read them via RequestStack in the listener (e.g. $request->query->get('room') for GET, $request->request->get('room') for POST). Keep filtering in the listener, not in the JS, so the server stays authoritative.
Timezones are handled for you now — partially. The bundle supports FullCalendar's timeZone parameter, so the dates delivered to your listener and (with the default serializer) the datetimes returned to the client follow the requested zone rather than being forced into the app default. You still own the data side: store UTC, and convert explicitly when your domain has wall-clock semantics (all-day events, "9am local" meetings).
Integration tips.
id, title, start, end, url) plus whatever extendedProps you want.private + ETag'd, don't defeat that by wrapping the route in a shared/global cache; if you decorate the controller, preserve the caching headers.CalendarEvent dispatch pattern; that's a legitimate use of the package.docs/upgrade-to-83.md step by step — it is deliberately one step per change and the changes are interlocking (strict ISO 8601 + immutable dates + final parser/serializer).start/end (including custom filter helpers, cron-ish scripts, or hand-built curl tests) must be ISO 8601. The old lenient fallbacks are gone; a malformed value now surfaces as a bad request rather than a silently defaulted range. Check your integration tests' fixtures for '2026-01-01'-style strings.$calendar->getStart() and Event::getStart() return DateTimeImmutable. Any code written against an older mutable expectation — $event->getStart()->modify('+1 day') with no assignment, or mutating a request date in place before querying — is now a no-op. Reassign: $start = $start->modify('+1 day').Event and override setStart()/setEnd() for normalisation or validation, that logic now also runs in the constructor — which is usually what you wanted, but double-normalising code (e.g. converting to UTC in both constructor and setter) can shift values twice.RequestParser and CalendarSerializer are final, wired via interface aliases (#146, #147). Subclassing them is no longer possible. To change parsing or the response envelope, register your own implementation against the interface alias in services.yaml — that is now the supported extension point. Keep the events array at the top level unless you also change the JS events function; FullCalendar will not read a nested array.private and revalidated with If-None-Match; a 304 has no body. Browsers handle this transparently for FullCalendar, but hand-rolled fetch() wrappers or server-side smoke tests that assert on a JSON body can trip on it. When debugging "empty calendar", check for a 304 before you blame the listener. Also confirm the ETag varies per user — if you add a custom serializer, make sure user-dependent data is not served under a shared tag.access_control and filter events per user inside the listener — otherwise you leak colleagues' calendars (and with ETag caching, potentially across sessions if you cache wrongly).start/end come from the client. Clamp the window (e.g. max 1 year) in the listener before hitting the DB, or a crafted request will happily ask for 1900–2100. Validate and cast the parsed dates; do not assume the parser's output is sane just because it is now strict about format.timeZone (#102) fixes the common drift, but the value is client-supplied: expect IANA names (Europe/Paris, UTC) and guard against garbage. All-day events still shift by a day if you mix local-midnight and UTC storage — store all-day as dates, not datetimes.SetDataEvent, mutable date getters, and non-final parser subclassing. Current versions use CalendarEvent with immutable getStart()/getEnd()/addEvent(). Pin your version in composer.json and read the tag-matched README plus the 8.3 upgrade guide, not blog posts.addEvent accepts arrays. Missing start/end produces events FullCalendar drops silently — no server error, just blank cells. Log additions in dev or assert on the array shape.[] vs {} vs nested), listener actually registered (debug:event-dispatcher), request dates accepted (ISO 8601), then the window itself. Nine times out of ten it is an unregistered listener or a range that is exclusive at the end.final (#148). If you were catching or extending bundle exceptions in your listener, catch the interfaces/base types instead — extending them is no longer an option.How can I help you explore Laravel packages today?