jean-sebastien-christophe/ux-calendar-bundle

A modern calendar bundle for Symfony 8 with Turbo and Stimulus, without FullCalendar

Maintainers

Package info

github.com/JsD3v/ux-calendar-bundle

Type:symfony-bundle

pkg:composer/jean-sebastien-christophe/ux-calendar-bundle

Transparency log

Statistics

Installs: 23

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.1.0 2026-08-09 16:04 UTC

README

Tests PHPStan

A lightweight calendar bundle for Symfony 8, built on Turbo, Stimulus and AssetMapper. It provides month, week and day views, event management forms and EasyAdmin helpers, without a heavy JavaScript dependency such as FullCalendar. No third-party CDNs are loaded by default.

Compatibility

  • PHP >= 8.4
  • Symfony FrameworkBundle, Form, Validator, TwigBundle, Console, Translation and AssetMapper ^8.0
  • Symfony UX Turbo and Stimulus Bundle ^2.0|^3.0
  • Doctrine ORM ^2.0|^3.0 and DoctrineBundle ^2.0|^3.0
  • EasyAdmin ^4.0|^5.0, optional, for the admin panel
  • Symfony UX ChartJS ^2.0|^3.0, optional, for the dashboard charts

Features

  • Month, week and day views with a built-in switcher and Turbo Streams updates
  • Shared or per-user calendars, with a configurable timezone
  • Week and day views rendered as an hourly grid (0:00–23:00 slots), plus an "all-day" row
  • Create, edit, delete and one-off date exclusion
  • Ready-to-use Event entity
  • CalendarEventInterface and CalendarEventRepositoryInterface contracts for custom entities
  • CalendarEventTrait to reuse the common Doctrine mapping
  • Headless usage: the calendar model is built by a standalone service, with an optional read-only JSON API
  • Optional authorization (roles and per-owner restriction) through a dedicated voter
  • Bootstrap theme by default, with default and tailwind variants and optional automatic detection
  • Optional EasyAdmin CRUD, calendar field and dashboard widget

Installation

composer require jean-sebastien-christophe/ux-calendar-bundle

Register the bundle in config/bundles.php:

JeanSebastienChristophe\CalendarBundle\CalendarBundle::class => ['all' => true],

Declare the routes in config/routes/calendar.yaml:

calendar_bundle:
    resource: '@CalendarBundle/src/Controller/'
    type: attribute

The default route is /events. To use /calendar instead, create config/packages/calendar.yaml:

calendar:
    theme: bootstrap
    timezone: Europe/Paris   # decides which day is "today"; defaults to PHP's
    assets:
        include_cdn: false
    route_prefix: /calendar
    views:
        enabled: [month, week, day]
        default: month
    features:
        all_day_events: true
        colors: true

The bundle registers the Doctrine mapping for its own Event entity (table calendar_events), so it is picked up by the schema tools out of the box. Create and apply the migration:

php bin/console make:migration
php bin/console doctrine:migrations:migrate
php bin/console cache:clear

The CSS assets are exposed through AssetMapper. No assets:install command is required.

The default theme is bootstrap, to stay consistent with EasyAdmin and the classes used by the templates. The bootstrap.css theme only maps the --bs-* variables: Bootstrap itself must therefore be loaded, otherwise the classes (btn, container, alert, …) used in the templates are left unstyled. There are two ways to provide it:

  1. Through your application's AssetMapper (recommended). The calendar's standalone pages automatically render importmap('app') (see the Stimulus section). If your importmap.php imports Bootstrap (for example import 'bootstrap/dist/css/bootstrap.min.css' in assets/app.js), it is loaded on /events with nothing else to do.

  2. Through the Bootstrap CDN, useful for a standalone rendering when the application does not embed Bootstrap:

calendar:
    theme: bootstrap
    assets:
        include_cdn: true

The tailwind, default and auto themes remain available through calendar.theme.

Stimulus

The Stimulus controller is exposed as a Symfony UX controller. Enable it in assets/controllers.json:

{
    "controllers": {
        "@jean-sebastien-christophe/ux-calendar-bundle": {
            "calendar": {
                "enabled": true,
                "fetch": "eager"
            }
        }
    }
}

Your application must start StimulusBundle, for example in assets/bootstrap.js:

import { startStimulusApp } from '@symfony/stimulus-bundle';

startStimulusApp();

The calendar's standalone pages (the @Calendar/calendar/base.html.twig layout) automatically render the importmap('app') entrypoint. This is what loads, on /events, both the calendar Stimulus controller and your application's assets (including Bootstrap if it is in your importmap.php). Your application must therefore expose an entrypoint named app (the Symfony default).

If your entrypoint has a different name, override the importmap block by creating templates/bundles/CalendarBundle/calendar/base.html.twig:

{% extends '@Calendar/calendar/base.html.twig' %}

{% block importmap %}
    {{ importmap('my_entrypoint') }}
{% endblock %}

To embed the calendar in your own layout (instead of the standalone page), override the same template so that it extends your application's layout:

{# templates/bundles/CalendarBundle/calendar/base.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    {{ calendar_theme_css()|raw }}
    {% block calendar_body %}{% endblock %}
{% endblock %}

Then open /events, or /calendar if you configured route_prefix: /calendar.

Exposed routes

{prefix} defaults to /events.

Method Route Name Description
GET {prefix} calendar_index Redirects to the default view (views.default)
GET {prefix}/{year}/{month} calendar_month Renders the monthly calendar
GET {prefix}/week/{date} calendar_week Renders the week containing {date} (Y-m-d)
GET {prefix}/day/{date} calendar_day Renders the {date} day (Y-m-d)
GET, POST {prefix}/new calendar_event_new Renders the form and creates the event
GET, POST {prefix}/{id}/edit calendar_event_edit Renders the form and updates the event
POST {prefix}/{id}/exclude/{date} calendar_event_exclude_date Excludes a date for an event
POST, DELETE {prefix}/{id} calendar_event_delete Deletes the event

The JSON API adds one more route, only if you import it (see Headless usage):

Method Route Name Description
GET {prefix}/api/events calendar_api_events Returns the calendar model as JSON
POST {prefix}/api/events calendar_api_event_create Creates an event
PATCH, PUT {prefix}/api/events/{id} calendar_api_event_update Updates an event
DELETE {prefix}/api/events/{id} calendar_api_event_delete Deletes an event
POST {prefix}/api/events/{id}/exclusions calendar_api_event_exclude Excludes one occurrence
DELETE {prefix}/api/events/{id}/exclusions/{date} calendar_api_event_include Restores an excluded occurrence

Headless usage

The calendar model — the monthly grid, the week/day columns, the events of each day — is built by CalendarViewBuilder, a service that depends on neither HTTP, routing nor Twig. Excluded dates are filtered there, so every consumer sees the same calendar.

Using the service directly

Inject it wherever you need a calendar, and render it however you want:

use JeanSebastienChristophe\CalendarBundle\Service\CalendarViewBuilder;

public function __construct(private readonly CalendarViewBuilder $calendar)
{
}

public function dashboard(): Response
{
    $model = $this->calendar->build('week', new \DateTimeImmutable('today'));

    // $model['day_columns'][0]['hours'][14] => the events starting at 14:00
    // $model['events']                      => the flat list for the range

    return $this->render('dashboard.html.twig', $model);
}

build() dispatches on the (normalized) view; buildMonth(), buildWeek() and buildDay() are available when you already know which one you want. The returned keys are year/month/calendar_data/events for a month, week_start/week_end/day_columns/events for a week, and day_columns/events for a day.

Navigation URLs (nav, view_links) are not part of the model: only a controller can generate them, and a headless front-end builds its own.

Authorization lives at the HTTP boundary, not in the builder. That is what keeps the service reusable, but it means a controller of your own calling CalendarViewBuilder directly enforces nothing. Inject EventAccessChecker and call denyUnlessGranted() yourself, as the bundle's controllers do. calendar.security.private is the exception: the per-event filter it installs applies inside the builder, so it protects every consumer.

JSON API

The API routes are not part of config/routes/calendar.yaml, because upgrading the bundle must never start publishing your events. Import them explicitly:

# config/routes.yaml
calendar_bundle_api:
    resource: '@CalendarBundle/config/routes/api.yaml'

Warning. Authorization is opt-in as well (see Security). Importing these routes without enabling calendar.security makes your events readable by anyone who can reach the URL.

GET {prefix}/api/events?view=month&date=2025-01-15

view falls back to the default view when absent or disabled; date defaults to today — resolved in calendar.timezone, not the server's — and answers 400 when it is not a valid Y-m-d date. The payload echoes the timezone it used, so a client can tell which day the isToday flags refer to. Events are listed once under events and referenced by id everywhere else, so a multi-day event is not repeated on each day it spans:

{
  "view": "month",
  "date": "2025-01-15",
  "enabledViews": ["month", "week", "day"],
  "timezone": "Europe/Paris",
  "year": 2025,
  "month": 1,
  "weeks": [
    [null, null, {"date": "2025-01-01", "day": 1, "isToday": false, "events": []}]
  ],
  "events": [
    {
      "id": 7,
      "title": "Réunion",
      "description": null,
      "start": "2025-01-15T10:00:00+01:00",
      "end": "2025-01-15T11:00:00+01:00",
      "allDay": false,
      "color": "#3788d8"
    }
  ]
}

A week answers range plus days[], each with allDay and hours (24 slots); a day answers a single-entry days[].

To expose your own fields, decorate the normalizer:

services:
    App\Calendar\MyEventNormalizer:
        decorates: JeanSebastienChristophe\CalendarBundle\Serializer\CalendarEventNormalizer

Writing through the API

The write endpoints submit their payload to the same EventType the Twig UI uses, so an application that customized its form or its event_class gets the same fields and the same validation constraints here. Send the field names of the form:

await fetch('/events/api/events', {
    method: 'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,       // see below
    },
    body: JSON.stringify({
        title: 'Réunion',
        startDate: '2025-01-15T10:00:00+01:00',
        endDate: '2025-01-15T11:00:00+01:00',
        allDay: false,
        description: null,
        color: '#ff0000',
    }),
});

Dates are accepted in the format the API emits (RFC 3339 with an offset), and also without an offset or with Z. What you read back can always be sent back.

Situation Answer
Created 201 + the normalized event
Updated 200 + the normalized event
Deleted 204, no body
Body is not a JSON object 400
Validation failed 422 + violations[]
Missing or invalid CSRF token 403
Denied by the voter 403

PATCH leaves the fields you omit untouched; PUT replaces the event, so an omitted field is cleared. A validation failure lists the offending fields:

{
  "error": "Validation failed.",
  "violations": [
    {"field": "startDate", "message": "Please enter a valid date and time."}
  ]
}

CSRF on the write endpoints

The API uses one token sent in the X-CSRF-Token header, rather than the per-form hidden field of the Twig UI: a JSON client has no rendered form to read a token from. The read endpoint hands the current token out under csrfToken, so a front-end is self-sufficient — fetch the calendar, keep the token, send it back on writes.

This matters only when a session cookie is what authenticates the request. An application whose API sits behind a stateless firewall (a bearer token in Authorization) is not exposed to CSRF and should turn it off:

calendar:
    api:
        csrf: false

Left enabled without a CSRF token manager available, the bundle raises an exception rather than pretending to protect the endpoints.

Cross-origin front-ends

The bundle sets no CORS header: doing so would fight with whatever your application already uses. If your front-end is served from another origin (a Vite dev server, say), configure it in your application, for example with nelmio/cors-bundle:

nelmio_cors:
    paths:
        '^/events/api':
            allow_origin: ['http://localhost:5173']
            allow_headers: ['Content-Type', 'X-CSRF-Token']
            allow_methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']
            allow_credentials: true

With cookie-based authentication, allow_credentials: true on the server and credentials: 'include' on the client are both required, and your session cookie needs SameSite=None; Secure to survive a cross-site request.

Security

By default the calendar performs no authorization: any request carrying a valid CSRF token may create, edit or delete any event. This is the historical behaviour, kept as the default so upgrading the bundle does not lock existing applications out of their own calendar.

Enable the checks explicitly:

# config/packages/calendar.yaml
calendar:
    security:
        enabled: true          # requires symfony/security-bundle
        admin_role: ROLE_ADMIN # bypasses every check, including ownership; null to disable
        owner_only: true       # restrict edit/delete to the event owner
        roles:                 # role required per action, null for none
            view: ~
            create: ROLE_USER
            edit: ROLE_USER
            delete: ROLE_CALENDAR_MANAGER

Once enabled, an unauthenticated user is denied every action, and the CALENDAR_EVENT_VIEW, CALENDAR_EVENT_CREATE, CALENDAR_EVENT_EDIT and CALENDAR_EVENT_DELETE attributes are enforced by the bundle's controllers and by the JSON API. You can use them in your own code too:

{% if is_granted('CALENDAR_EVENT_EDIT', event) %}
    <a href="{{ path('calendar_event_edit', {id: event.id}) }}">Edit</a>
{% endif %}

Enabling calendar.security without SecurityBundle installed raises an exception at compile time, rather than silently granting everything.

Per-owner restriction

With owner_only: true, an entity implementing CalendarEventOwnerInterface is only editable and deletable by its owner:

use JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventOwnerInterface;
use JeanSebastienChristophe\CalendarBundle\Trait\CalendarEventTrait;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity]
class Event implements CalendarEventInterface, CalendarEventOwnerInterface
{
    use CalendarEventTrait;

    #[ORM\ManyToOne(targetEntity: User::class)]
    private ?User $owner = null;

    public function getOwner(): ?UserInterface
    {
        return $this->owner;
    }
}

Ownership gates writing only: a calendar stays readable by everyone who passes the view role, which is what a shared team calendar needs. An event whose owner is null is editable by nobody (except admin_role). Entities that do not implement the interface are treated as shared and only subject to the role requirements.

Per-user calendars

If each user should only see their own events, turn the calendar private:

calendar:
    security:
        enabled: true
        private: true

VIEW then becomes owner-gated as well, and every model — the Twig views, the JSON API, anything built on CalendarViewBuilder — is filtered through the voter, so events the user may not view never reach the response. Setting private without enabled raises an exception rather than filtering nothing.

To filter on something other than ownership (a team, a tenant), implement CalendarEventFilterInterface and alias it:

services:
    JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventFilterInterface:
        alias: App\Calendar\TeamEventFilter

Known limitation. The EasyAdmin dashboard widget computes its statistics as SQL aggregates, which cannot go through the filter: under private the listed events are filtered but the totals still count every event. Scope them in your own repository (calendar.event_class) if the numbers themselves are sensitive.

EasyAdmin

Enabling calendar.security also applies the calendar attributes to the bundled EventCrudController (per-action permissions plus a per-entity VIEW check) and guards CalendarDashboardWidget, which reads the repository directly. Both stay permissive while calendar.security is disabled, since the attributes would otherwise match no voter and be denied.

Custom entity

The default entity is JeanSebastienChristophe\CalendarBundle\Entity\Event. To use your own entity, it must implement CalendarEventInterface. The CalendarEventTrait provides the common Doctrine mapping.

You can configure the entity with the install command:

php bin/console ux-calendar:install --event-class='App\Entity\MyEvent'
<?php

namespace App\Entity;

use App\Repository\MyEventRepository;
use Doctrine\ORM\Mapping as ORM;
use JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventInterface;
use JeanSebastienChristophe\CalendarBundle\Trait\CalendarEventTrait;

#[ORM\Entity(repositoryClass: MyEventRepository::class)]
#[ORM\HasLifecycleCallbacks]
class MyEvent implements CalendarEventInterface
{
    use CalendarEventTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    public function __construct()
    {
        $this->createdAt = new \DateTime();
        $this->updatedAt = new \DateTime();
    }

    public function getId(): ?int
    {
        return $this->id;
    }
}

The associated repository must implement CalendarEventRepositoryInterface, because the bundle's controller loads the monthly events through findByMonth().

<?php

namespace App\Repository;

use App\Entity\MyEvent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventRepositoryInterface;

/**
 * @extends ServiceEntityRepository<MyEvent>
 */
final class MyEventRepository extends ServiceEntityRepository implements CalendarEventRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, MyEvent::class);
    }

    public function findByMonth(int $year, int $month): array
    {
        $start = new \DateTime(sprintf('%d-%02d-01 00:00:00', $year, $month));
        $end = (clone $start)->modify('last day of this month')->setTime(23, 59, 59);

        return $this->createQueryBuilder('e')
            ->where('e.startDate BETWEEN :start AND :end')
            ->orWhere('e.endDate BETWEEN :start AND :end')
            ->orWhere('e.startDate <= :start AND e.endDate >= :end')
            ->setParameter('start', $start)
            ->setParameter('end', $end)
            ->orderBy('e.startDate', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

Week and day views (optional interface)

The week and day views work out of the box: the controller falls back to findByMonth() for the months covered. For a single query optimized over an arbitrary range, also implement CalendarEventRangeRepositoryInterface:

use JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventRangeRepositoryInterface;
use JeanSebastienChristophe\CalendarBundle\Contract\CalendarEventRepositoryInterface;

final class MyEventRepository extends ServiceEntityRepository implements
    CalendarEventRepositoryInterface,
    CalendarEventRangeRepositoryInterface
{
    // ... findByMonth() ...

    public function findByDateRange(\DateTimeInterface $start, \DateTimeInterface $end): array
    {
        return $this->createQueryBuilder('e')
            ->where('e.startDate BETWEEN :start AND :end')
            ->orWhere('e.endDate BETWEEN :start AND :end')
            ->orWhere('e.startDate <= :start AND e.endDate >= :end')
            ->setParameter('start', $start)
            ->setParameter('end', $end)
            ->orderBy('e.startDate', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

Then configure the bundle:

calendar:
    event_class: App\Entity\MyEvent

This value is used by the controllers, the argument resolver, EventType and EventCrudController. A createForm(EventType::class, $event) call therefore expects the configured entity, not the bundle's default entity.

As soon as event_class points somewhere else, the bundle stops registering the Doctrine mapping for its own Event entity: your schema only contains your table, with no leftover calendar_events. Mapping your entity is then up to your application, as usual.

EasyAdmin

The EasyAdmin helpers are optional. Install EasyAdmin if needed:

composer require easycorp/easyadmin-bundle

Then reference the provided CRUD in your dashboard:

use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use JeanSebastienChristophe\CalendarBundle\Admin\EventCrudController;
use JeanSebastienChristophe\CalendarBundle\Entity\Event;

yield MenuItem::linkToCrud('Events', 'fa fa-calendar', Event::class)
    ->setController(EventCrudController::class);

EventCrudController::getEntityFqcn() follows calendar.event_class, so with a custom entity link the menu item to that class instead:

yield MenuItem::linkToCrud('Events', 'fa fa-calendar', MyEvent::class)
    ->setController(EventCrudController::class);

See also:

Changelog

See CHANGELOG.md.

Quality

The repository does not version vendor/. Install the dependencies with Composer:

composer install

Useful commands before a PR or a tag:

composer validate --strict
composer analyse
composer test

composer analyse runs PHPStan at level 5 with the Symfony and Doctrine extensions.

Roadmap

  • Drag and drop to move events
  • Full recurring events (excludedDates currently excludes occurrences of a recurrence the bundle does not implement yet)
  • iCal export
  • Event categories

Contributing

  1. Fork the project
  2. Create a branch (git checkout -b feature/amazing-feature)
  3. Install the dependencies (composer install)
  4. Run composer analyse and composer test
  5. Push the branch and open a Pull Request

License

MIT

Support

For any question or issue, open an issue on GitHub: https://github.com/JsD3v/ux-calendar-bundle/issues