jean-sebastien-christophe / ux-calendar-bundle
A modern calendar bundle for Symfony 8 with Turbo and Stimulus, without FullCalendar
Package info
github.com/JsD3v/ux-calendar-bundle
Type:symfony-bundle
pkg:composer/jean-sebastien-christophe/ux-calendar-bundle
Requires
- php: >=8.4
- doctrine/doctrine-bundle: ^2.0|^3.0
- doctrine/orm: ^2.0|^3.0
- symfony/asset-mapper: ^8.0
- symfony/console: ^8.0
- symfony/form: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/stimulus-bundle: ^2.0|^3.0
- symfony/translation: ^8.0
- symfony/twig-bundle: ^8.0
- symfony/ux-turbo: ^2.0|^3.0
- symfony/validator: ^8.0
- twig/intl-extra: ^3.0
Requires (Dev)
- easycorp/easyadmin-bundle: ^4.0|^5.0
- phpstan/phpstan: ^2.2
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.5
- symfony/security-bundle: ^8.0
- symfony/security-core: ^8.0
- symfony/ux-chartjs: ^2.0|^3.0
- symfony/yaml: ^8.0
Suggests
- easycorp/easyadmin-bundle: For admin panel integration with CRUD controller and dashboard widgets (^4.0|^5.0)
- symfony/security-bundle: To enable authorization on the calendar actions via calendar.security (^8.0)
- symfony/ux-chartjs: For displaying event statistics charts in the dashboard (^2.0|^3.0)
README
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.0and 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
Evententity CalendarEventInterfaceandCalendarEventRepositoryInterfacecontracts for custom entitiesCalendarEventTraitto 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
defaultandtailwindvariants 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:
-
Through your application's AssetMapper (recommended). The calendar's standalone pages automatically render
importmap('app')(see the Stimulus section). If yourimportmap.phpimports Bootstrap (for exampleimport 'bootstrap/dist/css/bootstrap.min.css'inassets/app.js), it is loaded on/eventswith nothing else to do. -
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
CalendarViewBuilderdirectly enforces nothing. InjectEventAccessCheckerand calldenyUnlessGranted()yourself, as the bundle's controllers do.calendar.security.privateis 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.securitymakes 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 (
excludedDatescurrently excludes occurrences of a recurrence the bundle does not implement yet) - iCal export
- Event categories
Contributing
- Fork the project
- Create a branch (
git checkout -b feature/amazing-feature) - Install the dependencies (
composer install) - Run
composer analyseandcomposer test - 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