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

Calendar Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Add the container div and FullCalendar assets in a Twig template, pointing events at the route generated from load_route (the bundle ships a default template/JS you can copy).
  2. Create one listener on 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".
  3. In the listener, query your repository for the range and call $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:

  • GET is accepted again, with 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.
  • Start/end are strictly ISO 8601 — lenient parsing is gone. Non-ISO strings (2026-01-01, epoch timestamps, now, +1 month) now fail instead of falling back.
  • Immutable dates everywhere: 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.

Implementation Patterns

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:

  • FullCalendar's built-in eventClick / select callbacks POSTing to your own CRUD controller, then calendar.refetchEvents(). Simplest and most robust.
  • Drag-and-drop via 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.

  • Return DTOs, not entities: hardcode only what the UI needs (id, title, start, end, url) plus whatever extendedProps you want.
  • Emit ISO-8601 with offset. With strict ISO 8601 parsing inbound and the timeZone param honoured outbound, the main remaining failure is hand-built strings on the wire.
  • Cache the endpoint per user + range if the feed is expensive. Now that responses are 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.
  • If you already run API Platform or a JSON controller, you can skip the bundle's route entirely and keep only the CalendarEvent dispatch pattern; that's a legitimate use of the package.

Gotchas and Tips

  • 8.3.0 is a hard upgrade, not a patch. PHP 8.4 / Symfony 8 are required, so this cannot land on the same branch as an app still on older Symfony. Read 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).
  • Strict ISO 8601 inbound (#149). Anything your client sends as 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.
  • Immutable dates (#143, #144). $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').
  • Constructor now goes through the setters (#145). If you subclass 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.
  • ETag/304 semantics (#101). Responses are 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.
  • Public by default. The load route has no access control beyond your firewall. Add 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).
  • Unbounded ranges. 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 edge cases. Honouring FullCalendar's 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.
  • Naming drift in docs. Older tutorials reference 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.
  • Debugging a blank calendar. Check in this order: route reachable (curl it, and notice 304 vs 200), JSON 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.
  • Bundle exception classes are 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.
  • Assets are yours to manage. The bundle does not bundle FullCalendar's JS/CSS into an Encore entry; include it via npm or CDN yourself. Version mismatch between the JS and the payload format is the usual cause of "events do not render but the network tab looks fine".
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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package