Aller au contenu

Architecture

Cette page explique les choix structurants de Kirexo — pas comment faire, mais pourquoi. Pour l'inventaire complet des règles et des patterns attendus, voir CLAUDE.md à la racine. Pour le cadrage fonctionnel et la liste des services backing, voir projet.md.

Pourquoi dunglas/symfony-docker et FrankenPHP

Le bootstrap du projet se base sur dunglas/symfony-docker, qui s'appuie sur FrankenPHP. Cette brique nous donne HTTP/2, HTTP/3 et surtout le worker mode PHP natif — un interpréteur qui reste chargé entre les requêtes, sans FPM ni overhead de bootstrap Symfony à chaque appel. En dev, on y gagne une boucle de feedback rapide ; en prod, on y gagne une latence très basse sans jongler avec php-fpm, une reverse-proxy supplémentaire et un Caddy/Nginx séparé. Les prérequis locaux restent minimaux : git, docker, docker-compose, castor, jq, glab. Rien d'autre, volontairement — aucun PHP ou Node installé côté hôte, tout vit dans les conteneurs.

Le worker mode est activé de bout en bout : Symfony 7.4 et plus le supporte nativement via public/index.php, sans qu'il faille installer un package runtime/* ni surcharger APP_RUNTIME. La seule configuration vit dans le Caddyfile de FrankenPHP, qui pointe le worker sur l'entrée HTTP standard du projet. Conséquence pratique : pas de double chemin de boot (FPM d'un côté, worker de l'autre) à maintenir, et pas de dépendance tierce dont le support Symfony serait à surveiller.

Pourquoi des services synchrones par défaut (et Messenger réservé à l'asynchrone réel)

La logique applicative interne de Kirexo — rendu des pages, dashboards, actions simples — passe par des services applicatifs synchrones injectés directement : controller → service → repository. Les controllers restent fins : ils délèguent à un service, ils ne calculent pas eux-mêmes. C'est le mode par défaut, et il couvre la quasi-totalité des écrans de la plateforme.

Le bus Messenger n'est pas le chemin par défaut. Il est réservé à deux cas précis :

  • l'asynchrone réel — les écritures qu'on veut découpler de la requête utilisateur, acheminées via RabbitMQ (typiquement la diffusion d'un article publié vers les plugins) ;
  • une opération sur route dédiée de style API Platform (State Provider/Processor), où le pattern impose le passage par un message.

Le query.bus n'est pas imposé pour les lectures ni pour la logique interne : un dashboard qui compte les articles publiés appelle directement le repository via son interface, sans message de lecture intermédiaire.

Ce choix évite la cérémonie d'un message + Handler pour des cas qui n'en tirent aucun bénéfice (une lecture synchrone n'a pas besoin d'être découplée), tout en gardant le découplage asynchrone là où il compte vraiment : une publication qui déclenche N diffusions plugin ne doit jamais bloquer la requête, et l'isolement par transport permet de faire tomber un plugin sans faire tomber les autres ni le cœur. La règle complète est le pattern 1 de CLAUDE.md.

Pourquoi les plugins sont des Symfony Bundles isolés

Chaque destination de diffusion (Mastodon, RSS, web, API, et plus tard Telegram, LinkedIn, Discord…) est un Symfony Bundle autonome, implémentant une interface commune (DiffusionPluginInterface), auto-enregistré par tag de service, et relié au cœur uniquement par des événements.

La communication par événements se décline en deux modes, synchrone par défaut :

  • événements Symfony (EventDispatcher, synchrones, in-process) quand un plugin doit réagir ou contribuer dans le cycle de la requête ;
  • événements Messenger/RabbitMQ (un transport par plugin) pour la diffusion découplée — typiquement ArticlePublished. Chaque plugin consomme via son propre transport : un échec côté Mastodon ne fait pas tomber la diffusion RSS.

On ne bascule sur le mode asynchrone que quand le découplage le justifie réellement (une diffusion réseau lente qu'on ne veut pas voir bloquer la requête) ; sinon l'événement synchrone in-process suffit et reste plus simple à raisonner et à tester.

La contrainte forte est qu'un plugin doit pouvoir être supprimé sans toucher au reste. C'est cette contrainte qui force le couplage à passer exclusivement par l'interface et par les événements — pas par des appels directs, pas par une entité Doctrine partagée, pas par un service injecté depuis le cœur. Le prix à payer est une surface d'interface un peu plus large ; le bénéfice est qu'ajouter une nouvelle destination ne demande jamais de modifier le cœur. La règle complète est le pattern 2 de CLAUDE.md.

Pourquoi les fuseaux horaires sont découplés entre shell et PHP

Le conteneur php tourne en dev avec TZ=Europe/Paris (défini dans compose.override.yaml) mais PHP reste indépendamment verrouillé sur UTC via date.timezone = UTC dans frankenphp/conf.d/10-app.ini. Ce n'est pas une incohérence — c'est volontaire, et hérité des conventions de dunglas/symfony-docker.

La logique : TZ ne s'applique qu'à des consommateurs périphériques — le shell, la commande date, les logs Docker, la statusline de l'IDE. Mettre l'horloge du shell sur le fuseau local évite le décalage gênant qui apparaissait quand on lisait docker compose logs ou la statusline Claude Code (« mais il est 20 h chez moi, pourquoi ça affiche 18 h ? »). C'est un confort de dev, rien de plus.

À l'inverse, PHP doit rester en UTC partout — en dev comme en prod. Le stockage des DateTimeImmutable en base, les comparaisons de dates dans les Handlers, l'horodatage des événements Messenger, les expirations de cache et de tokens : tout doit s'aligner sur un fuseau unique et indépendant de la localisation de la machine. La conversion vers le fuseau de l'utilisateur final n'a lieu qu'au moment du rendu (filtres Twig date, formatters côté front), pas une seconde plus tôt. C'est cette discipline qui rend les fenêtres de publication d'articles cohérentes peu importe d'où l'utilisateur écrit ou consulte, et qui évite la classe d'incidents où une migration vers un serveur dans un autre fuseau décale silencieusement la moitié des données.

La prod renforce cette discipline en allant un cran plus loin : compose.prod.yaml ne définit pas TZ, donc le shell du conteneur reste lui aussi en UTC. C'est cohérent avec un environnement où il n'y a pas d'humain qui regarde la statusline en temps réel, et où l'alignement total shell/PHP sur UTC simplifie la corrélation entre logs Docker, logs Graylog et entrées Sentry. La référence détaillée de la variable TZ vit dans la Référence des variables d'environnement.

Pourquoi Diátaxis pour la documentation

La documentation suit Diátaxis, qui impose quatre quadrants strictement séparés : Tutoriels (apprendre en faisant), Guides (résoudre un problème précis), Référence (consulter la vérité) et Explications (comprendre le pourquoi — cette page). Chaque document appartient à un seul quadrant. Cette contrainte évite les pages-fourre-tout qui essaient d'expliquer et d'enseigner et de lister exhaustivement, et qui finissent par mal faire les trois.

La doc est publiée via mkdocs (thème material, image custom définie dans mkdocs/Dockerfile) et servie en local par le service Docker docs démarré avec la stack (castor docker:up), accessible sur http://localhost:8000. L'image custom ajoute deux plugins : git-revision-date-localized (date de dernière modif de chaque page, lue depuis git) et social (génération automatique des images Open Graph de partage). Toute modification qui change ce que l'utilisateur perçoit depuis un navigateur, ou qui introduit un concept utilisateur (nouveau plugin, nouvelle commande Castor exposée, nouvelle variable d'environnement), doit s'accompagner d'une doc à jour — c'est une règle non négociable de CLAUDE.md.