Introduction
Depuis la première mise en ligne de mon Calendar Bundle pour Symfony UX, l’objectif n’a jamais été de publier un package puis de le laisser évoluer seul dans son coin.
Le bundle a déjà été téléchargé et utilisé à plusieurs reprises, et les différents retours reçus, notamment sur LinkedIn, m’ont progressivement amené à réfléchir à la suite du projet.
Deux évolutions revenaient particulièrement souvent dans ma réflexion.
La première consiste à rendre le bundle davantage headless, afin de permettre son utilisation indépendamment de Twig. Parce que oui, tout le monde n’a pas forcément envie d’utiliser Twig… même dans un projet Symfony.
La seconde concerne la gestion des restrictions et des autorisations. Jusqu’à présent, le calendrier pouvait afficher et manipuler des événements, mais il manquait une véritable couche permettant de contrôler précisément ce qu’un utilisateur peut voir, créer, modifier ou supprimer.
C’est ici qu’entrent en jeu les Voters Symfony.
Cette mise à jour va donc principalement s’articuler autour de ces deux axes : rendre le Calendar Bundle plus flexible dans son intégration, et lui apporter une gestion des permissions beaucoup plus proche des standards de Symfony.
Pourquoi rendre le Calendar Bundle headless
Quand on publie un package open source, la première responsabilité est qu’il réponde au besoin pour lequel il a été créé : utile, fonctionnel, simple à intégrer. Celle-là, je la considérais remplie avec la 1.0.
Une deuxième arrive plus tard, et elle est moins confortable : jusqu’où impose-t-on ses propres choix techniques à ceux qui installent le package ?
Je suis un adepte de Twig — pour sa modularité, son intégration dans Symfony, ses performances. Twig 4 vient de sortir avec Symfony 8.1, ce n’est franchement pas le moment où je vais commencer à l’enterrer. Mais il faut savoir vivre avec son temps. Enfin… il paraît.
L’objectif n’était pas de remplacer Twig. Il était de faire en sorte que le Calendar Bundle cesse de dépendre d’une seule façon d’afficher ses données. Il expose désormais le calendrier via une API JSON : l’interface peut être construite en React, Vue, Next, Nuxt, dans une application mobile, ou dans quelque chose de complètement spécifique au projet.
Le bundle doit fournir la logique du calendrier, pas imposer la manière de l’afficher. Twig reste pleinement supporté et intégré, mais devient une option parmi d’autres. Dans un package open source, la philosophie front-end appartient au développeur qui l’utilise, pas à celui qui publie.
Des possibilités infinies grâce à une architecture agnostique
Le cœur du système : CalendarViewBuilder
Toute cette évolution tient dans un service : CalendarViewBuilder. C’est lui qui rend l’approche headless possible.
L’idée a été de sortir la logique de construction du calendrier du contrôleur, pour qu’elle ne dépende plus ni d’HTTP, ni du routing, ni de Twig. Le calendrier devient une brique métier autonome, capable de construire son propre modèle de données indépendamment de ce qui l’affichera ensuite.
php
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $eventClass,
private readonly array $views = ['enabled' => ['month'], 'default' => 'month'],
private readonly ?CalendarEventFilterInterface $filter = null,
?string $timezone = null,
) {
$this->timezone = new \DateTimeZone($timezone ?? date_default_timezone_get());
}Pas de RequestStack, pas d’UrlGeneratorInterface, pas d’Environment Twig. La signature dit déjà l’essentiel.
build($view, $date) normalise la vue demandée puis délègue à buildMonth(), buildWeek() ou buildDay(). Ces trois méthodes sont publiques : rien n’empêche de les appeler directement quand on sait déjà ce qu’on veut construire. Le modèle retourné change selon la vue :
- month :
year,month,calendar_data,events - week :
week_start,week_end,day_columns,events - day :
day_columns,events
L’objectif n’était pas pour autant de tout empiler dans ce service. Les URLs de navigation et les liens de changement de vue restent dehors : ils dépendent du routing de l’application, donc du contrôleur, seul capable de les générer. Même logique pour les autorisations liées aux actions utilisateur.
Une exception, et elle compte : le filtrage des événements. Le builder accepte un CalendarEventFilterInterface optionnel, appliqué pendant la construction du modèle. Le filtre reçoit la collection entière, pas un événement à la fois — il peut donc décider en connaissant l’ensemble.
C’est ce qui permet au mode calendar.security.private de protéger les données quel que soit leur consommateur : vue Twig, API JSON, ou intégration maison. Un événement qui ne doit pas être visible n’entre pas dans le modèle. Avec une réserve que je détaille plus bas : ça protège les consommateurs du builder, pas ceux qui interrogent le repository directement.Une logique réellement indépendante du front-end
Une logique réellement indépendante du front-end
Le fuseau horaire est injecté dans le builder via %calendar.timezone%. C’est lui qui détermine quelle cellule est « aujourd’hui », y compris quand le client qui consomme les données vit dans une autre zone un cas que l’API JSON rend beaucoup plus facile à déclencher qu’une vue Twig servie depuis le même serveur.
La récupération des événements suit la même logique de compatibilité, mais uniquement pour les vues semaine et jour, qui travaillent sur un intervalle arbitraire. La vue mois, elle, appelle findByMonth() directement.
Pour cet intervalle, deux chemins :
if ($repository instanceof CalendarEventRangeRepositoryInterface) {
return $this->visible($repository->findByDateRange($start, $end));
}
// Fallback : on agrège les mois couverts, puis on déduplique par id.Si le repository implémente CalendarEventRangeRepositoryInterface, le bundle utilise findByDateRange(). Sinon il retombe sur une agrégation mois par mois avec déduplication par identifiant. Les événements hors intervalle que ce fallback ramène sont ensuite écartés au moment de construire les colonnes, donc le résultat est le même juste moins efficace.
L’intérêt est de profiter d’une implémentation optimisée quand elle existe, sans casser les repositories personnalisés écrits pour la 1.0, qui ne connaissent que findByMonth().
Et surtout, il n’y a pas deux systèmes selon qu’on passe par Twig ou par l’API. Les vues Twig du bundle consomment exactement le modèle produit par CalendarViewBuilder. Le calendrier a une seule source de vérité. Twig, une API JSON, React, Vue : autant de façons d’exploiter les mêmes données.
Le headless n’a donc pas consisté à ajouter quelques routes. Il a consisté à séparer la logique du calendrier de sa représentation.
Une lecture JSON propre
Une fois le calendrier découplé de sa représentation, exposer une API devient presque une formalité. Le bundle ajoute une route de lecture, et une seule :
GET {prefix}/api/events?view=&date=Son rôle reste étroit. Le contrôleur ne reconstruit pas le calendrier et ne duplique aucune logique métier — il orchestre des briques qui existent déjà.
Il commence par vérifier via EventAccessChecker que l’utilisateur a le droit de consulter le calendrier. Puis il normalise la vue : absente ou désactivée, elle retombe sur la vue par défaut. La date suit la même logique — non fournie, c’est le jour courant dans le fuseau du calendrier ; invalide, c’est un 400 immédiat. Pas de fallback silencieux sur une date qu’on aurait devinée à la place du client.
Éviter de dupliquer les données
Le modèle du builder est ensuite transformé pour la consommation JSON. Le choix qui compte porte sur les événements : ils ne sont pas sérialisés dans chaque journée. Chaque cellule ne porte qu’une liste d’identifiants, et les données complètes sont regroupées une seule fois dans une collection events.
{
"view": "month",
"date": "2025-01-15",
"year": 2025,
"month": 1,
"enabledViews": ["month", "week", "day"],
"weeks": [
[null, null, { "date": "2025-01-01", "day": 1, "events": [] }, "…"],
["…", { "date": "2025-01-14", "day": 14, "events": [3] },
{ "date": "2025-01-15", "day": 15, "events": [3] },
{ "date": "2025-01-16", "day": 16, "events": [3] }, "…"]
],
"events": [
{ "id": 3, "title": "Salon", "start": "2025-01-14T09:00:00+01:00" }
]
}Le salon dure trois jours : il est référencé sur trois cellules et sérialisé une seule fois. Sans ça, il partirait trois fois dans la même réponse, et le front devrait dédupliquer lui-même pour ne pas l’afficher en triple. Les cellules hors mois valent null plutôt qu’un objet vide janvier 2025 commence un mercredi, les deux premières cases de la grille sont donc nulles.
Les vues semaine et jour changent de forme : une collection days[], chaque journée portant ses hours indexées de 0 à 23 et une liste allDay séparée. La semaine ajoute un range avec ses bornes ; le jour, non.
Les dates partent au format RFC 3339 avec leur offset 2025-01-15T14:00:00+01:00. Un client n’a pas à deviner le fuseau du serveur pour placer un événement au bon endroit.
Enfin, un détail qui n’en est pas un : les dates exclues d’un événement récurrent sont retirées du payload. Elles l’étaient auparavant par les templates Twig, ce qui voulait dire qu’un consommateur JSON aurait reçu une occurrence que le calendrier n’affiche pas. Le genre de divergence qu’on ne découvre qu’en branchant un second consommateur.
Une sérialisation extensible
La transformation des événements passe par CalendarEventNormalizerInterface. L’implémentation par défaut expose les dates au format RFC 3339 avec leur offset, comme vu plus haut.
Là encore, pas question d’enfermer le développeur dans une représentation imposée. Le normalizer est déclaré derrière un alias dans la configuration des services : il peut donc être décoré ou remplacé pour ajouter ses propres champs à la réponse. Une couleur, un propriétaire, une catégorie, un statut métier — sans toucher au cœur du bundle.
C’est le même raisonnement que pour le repository : le bundle fournit une implémentation qui marche tout de suite, et un point d’accroche pour ceux qui ont besoin d’autre chose.
Et le CSRF dans tout ça ?
Un client headless n’a pas de formulaire Symfony où récupérer un token. Le bundle doit donc le fournir autrement.
Quand la protection CSRF de l’API est activée, la réponse de lecture embarque un csrfToken, émis sous l’identifiant calendar_api. Le client le renvoie ensuite dans l’en-tête X-CSRF-Token, et ApiCsrfValidator s’occupe de la validation.
Le comportement reste configurable. CsrfTokenManagerInterface est une dépendance optionnelle : si calendar.api.csrf est activé sans token manager disponible, le bundle lève une LogicException plutôt que de laisser tourner l’application dans un état ambigu activé côté config, inopérant côté code. À l’inverse, dans une architecture stateless derrière un firewall API avec son propre mécanisme d’authentification, la protection se désactive et le validator laisse passer.
Sécurisé par défaut, mais compatible avec des architectures Symfony très différentes.
Tout ça découle du découpage fait en amont. Le contrôleur API ne contient pas la logique du calendrier : il vérifie l’accès, interprète la requête HTTP, appelle le builder, adapte le résultat en JSON. Le repository reste la source des événements, exactement comme côté Twig. L’API n’est pas une seconde implémentation du bundle, c’est une porte d’entrée de plus vers le même moteur.
Une sécurité simple, mais nécessaire
Avant la 1.1.0, la protection du Calendar Bundle reposait sur les tokens CSRF des formulaires. Autrement dit : token valide, action autorisée. Pas de notion d’utilisateur, pas de propriétaire d’événement, pas de contrôle fin sur ce qui est permis.
Pour un calendrier interne et partagé, ça passait encore. À partir du moment où le bundle expose ses données via une API JSON, beaucoup moins.
C’est pour ça que le headless et la couche de sécurité arrivent dans la même version. Ouvrir le bundle obligeait à contrôler correctement ce qui peut être vu et modifié.
Un Voter seul ne sécurise rien
Les Voters sont un excellent mécanisme de décision. Mais un Voter ne protège rien tout seul : il attend qu’on l’interroge. Si personne ne pose la question, il ne répond pas, et l’action passe.
La sécurité du bundle repose donc sur trois briques complémentaires : une qui décide, une qui applique, une qui empêche les données de sortir.
CalendarEventVoter : décider
Le Voter détermine si une action est autorisée. Sa logique est volontairement simple et prévisible :
- pas d’utilisateur authentifié → refus ;
- utilisateur porteur du rôle configuré en
admin_role→ autorisation immédiate, sans vérification de propriété ; - sinon, le Voter contrôle que l’utilisateur détient le rôle nécessaire à l’action demandée.
Quand owner_only est activé et que l’événement implémente CalendarEventOwnerInterface, certaines opérations l’édition et la suppression sont réservées au propriétaire. En mode private, la propriété conditionne aussi la simple consultation.
Un détail qui compte : la comparaison entre l’utilisateur connecté et le propriétaire passe par getUserIdentifier(), pas par une comparaison d’objets. Doctrine peut très bien produire deux instances distinctes pour le même utilisateur en base hydratation dans deux contextes, entité détachée, proxy non initialisé et un === renverrait alors false sur un propriétaire pourtant légitime. Une comparaison d’identifiants ne se fait pas piéger.
EventAccessChecker : appliquer
La deuxième brique fait le lien entre les contrôleurs du bundle et le système d’autorisation Symfony. C’est elle qui pose la question au Voter, en début de chaque action côté Twig comme côté API JSON.
Cette indirection sert aussi à autre chose : le bundle ne dépend pas obligatoirement du composant Security. AuthorizationCheckerInterface est injecté de façon optionnelle, et quand la sécurité du calendrier est désactivée, le checker laisse passer sans rien demander à personne.
new EventAccessChecker() // sécurité désactivée
new EventAccessChecker($authorizationChecker, enabled: true) // sécurité activeÇa compte pour la compatibilité : un projet qui utilise le calendrier sans avoir besoin de cette couche continue de fonctionner à l’identique après mise à jour.
VoterEventFilter : empêcher les données de sortir
La troisième pièce n’entre en jeu qu’en mode privé. VoterEventFilter implémente CalendarEventFilterInterface et est injecté dans le CalendarViewBuilder le filtre optionnel dont je parlais plus haut.
Le déplacement est ce qui compte. Une sécurité classique contrôle à la frontière HTTP : le contrôleur vérifie qu’une action est permise, puis continue. Ici, le filtrage descend dans la construction du modèle. Un événement auquel l’utilisateur n’a pas accès n’est pas écarté à l’affichage il n’entre jamais dans les données retournées.
Résultat : tous les consommateurs du builder sont protégés, pas seulement les contrôleurs fournis par le bundle. Un contrôleur maison qui injecte CalendarViewBuilder hérite du filtrage sans avoir rien à faire.
C’est aussi ce qui explique pourquoi le builder accepte un filtre qui reçoit la collection entière : la décision d’autorisation se prend sur l’ensemble des événements récupérés, en une passe.
Éviter une dépendance inutile au composant Security
Les permissions du calendrier sont regroupées dans une classe dédiée, CalendarPermissions, qui ne dépend pas du composant Security.
L’extension de Dependency Injection, les contrôleurs et les autres services du bundle référencent donc tous les mêmes constantes sans avoir à charger le Voter ni security-core dans une application qui ne s’en sert pas. Les tests de l’API le montrent bien : CalendarPermissions::VIEW est utilisé pour vérifier l’accès, sans que rien d’autre du système d’autorisation ne soit nécessaire.
Petit détail d’architecture, mais c’est ce qui permet à la couche de sécurité de rester réellement optionnelle plutôt que d’être une dépendance déguisée.
Une sécurité volontairement opt-in
Cette couche a plusieurs limites assumées. La première, et la plus importante : elle est désactivée par défaut.
calendar:
security:
enabled: falseUne application qui se contente de mettre le bundle à jour ne voit donc pas son comportement changer du jour au lendemain. C’est un choix de compatibilité ascendante : la fonctionnalité est là, son activation reste une décision explicite de l’intégrateur.
Ce qui veut dire, très concrètement : installer la 1.1.0 ne sécurise pas un calendrier existant. Il faut activer la configuration.
La frontière HTTP reste une frontière
Avec enabled: true, les contrôleurs du bundle passent par EventAccessChecker. Mais un développeur qui écrit son propre contrôleur et injecte directement CalendarViewBuilder court-circuite cette couche sans rien faire de mal, simplement parce que le contrôle vit dans les contrôleurs et qu’il n’utilise pas les siens. À lui, dans ce cas, d’appeler EventAccessChecker.
Le mode private: true échappe à ce raisonnement, puisque son filtre agit dans le builder lui-même. C’est donc cette configuration qu’il faut retenir dès qu’un calendrier contient des événements qui ne doivent jamais être visibles par certains utilisateurs. Le reste relève du confort d’affichage.
owner_only n’a de sens qu’avec un propriétaire
L’option n’a d’effet que si les événements implémentent CalendarEventOwnerInterface. Sinon aucune propriété n’est vérifiable, et la règle est ignorée silencieusement, ce qui vaut la peine d’être su : activer owner_only sur des entités qui n’implémentent pas l’interface donne un faux sentiment de restriction.
L’entité Event fournie par le bundle ne l’implémente pas, délibérément. La notion de propriétaire dépend trop du modèle métier de chaque application pour être imposée par défaut.
Le filtre privé reste un filtre PHP
VoterEventFilter agit après la récupération des événements. C’est un filtre d’affichage exécuté en PHP, pas une restriction posée dans la requête SQL. Les événements interdits sortent bien de la base, ils sont écartés juste après.
Pour l’affichage du calendrier, ça suffit. Mais tout ce qui calcule en base ne passe pas par là : un COUNT de widget de dashboard, une statistique EasyAdmin, un export construit sur une requête maison. Ces agrégats compteront les événements que l’utilisateur n’a pas le droit de voir. Dans ces cas-là, la restriction doit descendre au niveau SQL, dans un repository personnalisé.
C’est le même compromis que pour les dates exclues, retirées cellule par cellule plutôt qu’écartées par la requête.
Le rôle administrateur est un vrai bypass
admin_role court-circuite les autres règles, y compris la vérification du propriétaire. C’est le but : disposer d’un rôle capable d’administrer tout le calendrier.
Mais ça veut dire que sa valeur mérite d’être choisie, pas reprise par réflexe. Câbler ROLE_ADMIN parce que c’est celui qu’on a sous la main donne à tous les administrateurs de l’application un accès complet à tous les événements, y compris ceux qui ne les concernent pas. Un ROLE_CALENDAR_ADMIN dédié coûte une ligne de configuration.
Une base de sécurité, pas une politique imposée
Cette couche ne décide pas de la politique de sécurité à la place de l’application. Elle fournit les briques pour l’exprimer.
Pour un calendrier partagé entre collègues, enabled: true suffit largement. Pour un calendrier réellement cloisonné, où les événements des uns ne doivent jamais atteindre les autres, private: true est le seul choix cohérent c’est le seul qui agit dans le modèle plutôt qu’à la frontière HTTP.
Ça rejoint la philosophie du projet : le bundle reste autonome et fonctionne seul, mais donne aux intégrateurs de quoi aller plus loin quand ils en ont besoin.
Il reste des choses à améliorer. Rapprocher certains contrôles du repository, pour restreindre les données en amont plutôt que de les filtrer après coup, en fait partie. Mais la base est nettement plus saine qu’en 1.0.
Ce que prépare la 1.1.0
Cette version est moins spectaculaire que la première il n’y a rien de nouveau à regarder. Elle compte probablement davantage pour la suite du projet.
Le point de départ était simple : rendre le bundle utilisable avec autre chose que Twig. En avançant, il est vite devenu clair qu’exposer davantage les données obligeait à reprendre leur circulation et leur protection. Et forcément, vous commencez à me connaître : dès qu’une séparation peut être rendue plus propre, difficile de s’arrêter là.
Le bundle est désormais organisé autour de contrats explicites repository, filtre, normalizer, permissions. Une approche contract-first, plus que Ports & Adapters au sens strict : le builder reste couplé à Doctrine, la persistance n’est pas derrière un port. C’est la présentation qui a été découplée, pas le stockage.
Le principe qui guidait la 1.0 n’a pas bougé pour autant : installer le package et obtenir un calendrier fonctionnel, immédiatement. L’extensibilité ne doit pas se payer en configuration au premier composer require. Ce qui change, c’est que les projets qui en ont besoin peuvent maintenant remplacer, décorer ou brancher les briques une par une.
Deux directions m’intéressent pour la suite. Des templates prêts à l’emploi, d’abord, pour couvrir plus vite les cas d’usage courants. Et un connecteur MCP, plus expérimental : permettre à un LLM d’interagir avec le calendrier via des outils définis. Pas pour laisser une IA modifier les données librement au contraire, pour s’appuyer sur les contrats et la couche d’autorisation qui existent désormais, et n’exposer que des actions contrôlées : chercher des événements, consulter une période, créer ou modifier dans la limite des permissions.
C’est probablement là que l’open source devient intéressant : publier une première réponse à un besoin, puis laisser les usages réels pousser l’architecture plus loin.