Socle Symfony (multisite, CMS, contact, i18n)
Bundle Symfony réutilisable fournissant des briques transverses (multisite, CMS Markdown, formulaire de contact, i18n, réglages, upload, back-office extensible) qu'une application hÎte assemble et configure. Le composant n'est pas une application : il n'a ni entité utilisateur, ni firewall, ni route front publique, ni page d'accueil propre. Il fournit des contrats, des entités, des services et des points d'extension.
- Type de paquet :
symfony-bundle, namespace PSR-4Tlr\Symfony\âsrc/. - Cible : PHP moderne (sĂ©rie 8.x), Symfony 8.x, Doctrine ORM 3.x.
- Distribution par VCS Git (pas de dépÎt public de paquets).
PérimÚtre : ce que le socle fournit / délÚgue
| Fourni par le socle | Délégué à l'application hÎte |
|---|---|
| Entités CMS/site/réglages (auto-mappées) | Migrations Doctrine (aucune livrée) |
| Services d'intĂ©gration (rĂ©solution site, rendu, uploadâŠ) | EntitĂ© User + firewall/sĂ©curitĂ© (ROLE_ADMIN) |
| Contrats (interfaces) à implémenter | Routes front (accueil, contact, docs, sitemap) |
| Extensions et globales Twig | Templates de base (html.twig, cms/index.html.twig) |
ContrĂŽleurs back-office admin/config et admin/pages |
Envoi de mail, traductions, assets (Bootstrap/Turbo/Stimulus) |
Architecture d'exĂ©cution (chaĂźne par requĂȘte)
Sur l'Ă©vĂ©nement noyau REQUEST (requĂȘte principale uniquement), trois abonnĂ©s d'Ă©vĂ©nements interviennent dans un ordre de prioritĂ© contrĂŽlĂ© :
| Priorité | Abonné | RÎle |
|---|---|---|
| 64 | SiteSubscriber |
RĂ©sout le Site courant, l'inscrit dans SiteContext et l'attribut de requĂȘte site |
| (routeur) | RouterListener Symfony | Résout la route |
| 20 | LocaleSubscriber |
Synchronise la locale URL â session (aprĂšs le routeur) |
| 16 | SiteAccessSubscriber |
Cloisonne les sites vitrine (aprĂšs le routeur, avant le firewall) |
SiteContext est un service partagĂ© portant l'Ă©tat « site courant » (setSite/getSite/hasSite) sur le postulat « une requĂȘte = un process ». Il est consommĂ© par les contrĂŽleurs, le rendu CMS et l'extension Twig qui expose la variable globale site.
Configuration sémantique du bundle
NĆud racine tlr_symfony :
| Clé | Type | Défaut | RÎle |
|---|---|---|---|
multisite |
booléen | true |
Opt-in multisite. false = mode single-site (site primaire sans matching host, sans cloisonnement) |
vitrine_allowed_routes |
liste de scalaires | [] |
Liste blanche des noms de routes autorisés sur un site vitrine (sans préfixe localized_) |
ParamĂštres du noyau applicatif repris automatiquement par injection nommĂ©e : kernel.enabled_locales â $enabledLocales, kernel.default_locale â $defaultLocale. Une variable d'environnement optionnelle (URI par dĂ©faut) alimente le seed de site (repli sur host local si absente).
Le bundle est un micro-bundle (config sémantique + extension inline). Sa méthode build() enregistre deux tags d'autoconfiguration (ConfigSectionInterface, BoPageInterface) : tout service implémentant ces interfaces est collecté automatiquement, sans cùblage.
Multisite
Entité Site (table site)
Représente un domaine servi. Champs observables :
| Champ | Type | RĂŽle |
|---|---|---|
host |
string(255), unique | Host canonique (sans schéma ni port) |
aliases |
json nullable | Hosts supplémentaires résolvant vers ce site |
slug |
string(64), unique | Identifiant court stable (^[a-z0-9-]+$) |
label |
string(128) | Libellé d'affichage BO |
name |
string(80) nullable | Nom applicatif (fallback : host) |
localeDefault |
string(8), défaut fr |
Locale par défaut du site |
layoutTemplate |
string(128), défaut cms/index.html.twig |
Template que les pages CMS étendent |
theme |
string(64) nullable | Clé de thÚme (sélection CSS) |
brandDomain / brandTld |
nullable | Décomposition de marque |
contactEmail / senderEmail |
string(255) nullable, validés Email |
Destinataire / expéditeur des mails du domaine |
homeSlug |
string(255) nullable | Slug de la page CMS servie en / |
ogImageDefault |
string(512) nullable | Image Open Graph par défaut |
enabled |
bool, défaut true |
Site actif |
isPrimary |
bool, défaut false |
Site de repli (un seul attendu) |
createdAt / updatedAt |
datetime_immutable | Horodatage (lifecycle callbacks) |
Contraintes d'unicité sur host et slug. Méthode matchesHost(string): bool : teste host canonique et alias, insensible à la casse.
Résolution et cloisonnement
SiteResolver::resolve(string $host): ?Site: normalise, parcourt les sites actifs, retourne le premiermatchesHost(), sinonprimary(). Cache mĂ©moire (un chargement par requĂȘte viaSiteRepository::findAllEnabled(), triĂ©sisPrimary DESC, label ASC).SiteResolver::primary(): ?Site: premier siteisPrimary, sinon premier site actif.- Cloisonnement vitrine (
SiteAccessSubscriber) : sur un site vitrine (non primaire) dont le host matche réellement, toute route applicative absente devitrine_allowed_routes(préfixelocalized_retiré) renvoie404. Ignoré en mode single-site, pour le site primaire, pour les routes internes (préfixe_) et si aucune route n'est matchée. Le site primaire garde l'application entiÚre.
Garantie d'au moins un site
Commande idempotente tlr:site:ensure-default (options --host, --label, --slug) : crée un Site primaire par défaut seulement si aucun n'existe ; ne touche jamais les sites gérés par l'app/BO. à jouer au déploiement aprÚs les migrations (le resolver exige ℠1 site, faute de quoi l'app renvoie 404 partout).
Moteur CMS
Entité CmsContent (table cms_content)
Page éditoriale en Markdown, rattachée obligatoirement à un Site. Slug unique par site (contrainte sur site_id, slug), pas globalement.
| Champ | Type | RĂŽle |
|---|---|---|
site |
ManyToOne Site, non null | Domaine propriétaire |
title |
string(255) | Titre |
slug |
string(255), ^[a-z0-9-]+$ |
Segment d'URL ; auto-dérivé du titre si absent |
excerpt |
text nullable | Résumé (méta description / OG) |
markdown |
text, non vide | Corps en Markdown |
status |
string(20) | draft | published | archived (défaut draft) |
publishedAt |
datetime nullable | Posée au premier publish() |
updatedAt |
datetime | MAJ auto (lifecycle) |
visibility |
string(20) | public | protected | private (défaut public) |
type |
string(50), défaut page |
Type libre ; valeur spéciale block |
position |
int, défaut 0 | Ordre de nav (tri ASC) |
noindex |
bool, défaut false |
Exclut de l'indexation (robots + sitemap) |
ogImage |
string(512) nullable | Image OG (défaut = celle du site) |
layout |
string(50) nullable | Clé de mise en page (default | three-col) |
author |
ManyToOne CmsUserInterface, non null |
Auteur (résolu par l'app) |
tags |
ManyToMany CmsTag |
Ătiquettes |
images |
OneToMany CmsImage |
Images rattachées |
Constantes exposées : STATUSES, VISIBILITIES, LAYOUTS, TYPE_BLOCK. Helpers : isPublished(), isPublic(), publish().
Statut (cycle de vie) et visibilité (portée d'accÚs) sont deux axes indépendants. Le socle ne fait pas respecter la visibilité protected/private : CmsContentRepository::findPublished() filtre le statut publié et exclut les blocs, mais l'arbitrage d'accÚs reste au contrÎleur applicatif.
Blocs de chrome (type = block)
Fragment Ă©ditable de chrome (nav, footer, bandeau) : jamais servi comme page, jamais indexĂ©, exclu des requĂȘtes pages/sitemap/nav. Rendu dans le layout via la fonction Twig site_block(slug).
CmsTag (table cms_tag)
Vocabulaire de classement partagé entre sites (non scopé). name + slug unique (auto-dérivé). Le tag doc a un rÎle : findDocNavBySite() liste les pages taguées doc d'un site, publiées, triées par position ASC, pour bùtir une nav de documentation.
CmsImage (table cms_image)
Métadonnées d'un fichier uploadé : filename, mime, size, width, height, checksum (dédup via findOneByChecksum), createdAt. Rattachée à un owner (CmsUserInterface, non null), un site nullable (onDelete SET NULL, bibliothÚque média par domaine), optionnellement un CmsContent. Expose getSlug(): ?string retournant null (contrat attendu par l'uploader).
Ădition et rendu
CmsContentType(formulaire BO) : expose site, titre, slug, excerpt, markdown, statut, visibilité, type, position, noindex, ogImage, layout, tags. L'auteur est posé cÎté contrÎleur (utilisateur courant). Le socle ne fournit pas de contrÎleur CMS : l'app cùble le CRUD front et BO.- Layouts fournis (
templates/cms/layouts/) :default.html.twig(étendsite.layoutTemplate, remplit métas SEO/OG, rendbody_html) etthree-col.html.twig(mise en page docs à 3 colonnes ; référence un contrÎleur et une route de doc cÎté application).
Contact & anti-spam
ContactDTO
Objet de transport (data_class du formulaire). Validation :
| Champ | Contraintes |
|---|---|
name |
NotBlank, Length 3â200 |
email |
NotBlank, Email |
message |
NotBlank, Length 10â2000 |
ContactType (formulaire)
Champs name, email, message (textarea avec compteur via contrÎleur Stimulus character-counter), send. Libellés = clés de traduction (catalogue fourni par l'app). Option max_length (défaut 2000) pilotant la contrainte, l'attribut HTML maxlength et le compteur.
Honeypot anti-bot : champ website non mappĂ©, masquĂ© (tabindex -1, aria-hidden, autocomplete off). Le socle dĂ©finit le champ mais ne vĂ©rifie pas qu'il est vide â la dĂ©tection est laissĂ©e Ă l'app.
Contrat anti-spam
interface ContactSpamAnalyzerInterface { public function analyze(string $name, string $email, string $message): SpamAnalysisResult; public function isEnabled(): bool; }
SpamAnalysisResult (readonly) : bool $isSpam, string $reason, bool $apiError = false. Le socle définit le contrat mais ne fournit aucune implémentation (l'app branche son analyseur, potentiellement distant).
Internationalisation (i18n)
MĂ©canique de locale basĂ©e sur prĂ©fixe d'URL /{_locale}/âŠ, la locale par dĂ©faut restant sans prĂ©fixe.
- Convention de routage : locale par défaut = route « nue » ; autres locales = route préfixée
localized_avec paramÚtre_locale. Convention structurante : le retrait du préfixelocalized_conditionne aussi le cloisonnement vitrine. LocaleSubscriber(aprÚs le routeur) : si l'URL porte une locale activée, la mémorise en session ; sinon applique la locale de session ou la locale par défaut. Ne fait rien sans session préalable.LocalizedUrlGenerator:generate($route, $params, $refType),getLocalizedRoute($route),getRouteParams($params).
Réglages applicatifs (Settings)
Magasin clĂ©âvaleur persistant. EntitĂ© AppSetting (table app_setting) : name string(128) clĂ© primaire, value text nullable.
Service AppSettings (readonly) :
| Méthode | Comportement |
|---|---|
get(name, default = null): ?string |
Valeur ou défaut |
set(name, value): void |
Upsert + flush immédiat |
getBool(name, default = false): bool |
Vrai si valeur â {1, true, yes, on} |
setBool(name, value): void |
Stocke 1/0 |
getDateTime(name): ?DateTimeImmutable |
Parse, null si vide/invalide |
Distinct du hub de configuration BO : AppSettings persiste les valeurs, le hub organise l'UI.
Markdown & upload de fichiers
MarkdownRenderer
toHtml(string $markdown, string $currentPath = '', bool $rewriteDocLinks = true): string. Pipeline CommonMark durci :
html_input => escape(HTML brut Ă©chappĂ©),allow_unsafe_links => false(liens dangereux neutralisĂ©s).- Extensions : cĆur CommonMark, GitHub-Flavored (tables, task lists, barrĂ©, autoliens), coloration syntaxique serveur, FrontMatter (retire le cartouche YAML du corps rendu).
- Réécriture optionnelle des liens
.mdvers une route de viewer de doc (défautdocs.show, injectable), avec navigation AJAX ; ne traite que les chemins.md, résout les chemins relatifs en mémoire (sans accÚs disque). à désactiver hors viewer (le rendu CMS l'appelle avecfalse).
FileUploader
upload(object $entity, UploadedFile $file): ?string â chemin relatif stockĂ© ou null si refus. Garde-fous :
- Limite de taille (plafond en Mo ; au-delĂ , refus + log).
- Validation MIME réelle via
finfo(pas le MIME déclaré) ; liste blanche image (jpeg,png,webp,gif). - Nommage : slug (
entity->getSlug()ou nom d'origine) + identifiant unique + extension devinée. - Rangement sous le répertoire public, dans un sous-dossier dérivé du nom court de la classe. Répertoire d'upload surchargeable par config service.
delete(string $fileName): bool â protection anti-traversal : retire les ../, rĂ©sout le chemin rĂ©el, vĂ©rifie qu'il reste sous le rĂ©pertoire public, sinon refuse + log ; idempotent (vrai si le fichier n'existe pas).
Contrat entitĂ© : toute entitĂ© passĂ©e Ă upload() doit exposer getSlug(): ?string (null autorisĂ© â nom d'origine).
Back-office extensible
Deux registres à points d'extension. N'importe quel bundle/app ajoute une entrée en implémentant une interface auto-taggée, sans toucher la navigation ni les contrÎleurs.
Hub de configuration (/admin/config)
ConfigSectionInterface : key() (segment d'URL kebab-case), category() (groupe de nav), label(), icon(), priority() (tri croissant), handle(Request): Response (fragment HTML ou RedirectResponse pour PRG). ConfigSectionRegistry collecte les sections taggées, trie par priorité, groupe par catégorie (get(key), first(), groupedByCategory()). ConfigController (ROLE_ADMIN) rend le hub avec la section active dans un turbo-frame config.
Pages BO (/admin/pages)
BoPageInterface : key(), group(), label(), icon(), priority(), handle(Request): Response. BoPageRegistry collecte, trie, groupe par group(). BoPageController (ROLE_ADMIN) rend la page plein cadre.
Les deux contrÎleurs Admin portent ROLE_ADMIN et supposent un firewall applicatif : la sécurité HTTP n'est pas fournie par le socle.
Contrats publics (récapitulatif intégrateur)
Interfaces à implémenter
| Interface | Qui l'implémente | Méthodes |
|---|---|---|
CmsUserInterface |
l'entité User de l'app (via resolve_target_entities) |
getId(): ?int |
ContactSpamAnalyzerInterface |
service app (optionnel) | analyze(...), isEnabled() |
ConfigSectionInterface |
services app/bundle (auto-taggés) | key, category, label, icon, priority, handle |
BoPageInterface |
services app/bundle (auto-taggés) | key, group, label, icon, priority, handle |
Auto-mapping Doctrine et résolution d'entité
Toutes les entités sont annotées par attributs et découvertes par Doctrine (auto-mapping ou mapping explicite du namespace Tlr\Symfony\Entity). Aucune migration livrée : l'app génÚre les siennes (tables site, cms_content, cms_image, cms_tag, app_setting). CmsContent.author et CmsImage.owner ciblent l'interface CmsUserInterface : l'app doit la résoudre vers son entité concrÚte, sinon le schéma Doctrine est invalide.
doctrine: orm: auto_mapping: true resolve_target_entities: Tlr\Symfony\Contract\CmsUserInterface: App\Entity\User
Services publics autowirables
SiteContext, SiteResolver, LocalizedUrlGenerator, MarkdownRenderer, FileUploader, AppSettings, ConfigSectionRegistry, BoPageRegistry, et les repositories (SiteRepository, CmsContentRepository, CmsImageRepository, CmsTagRepository, AppSettingRepository).
Extensions et globales Twig
| Nom | Type | RĂŽle |
|---|---|---|
site |
variable globale | SiteContext::getSite() (peut ĂȘtre null) |
site_block(slug) |
fonction | Rend un bloc CMS publiĂ© (Markdown â HTML), repli chaĂźne vide |
enabled_locales() / default_locale() |
fonctions | Locales du noyau |
localized_route(name) |
fonction | Nom de route adapté à la locale courante |
route_exists(name) |
fonction | Vrai si la route existe (sélecteur de langue) |
Templates Ă fournir par l'app
html.twig (base des templates admin/config/front/erreurs), cms/index.html.twig (base du layout par défaut et du three-col), traductions du formulaire de contact, route de viewer de doc si la réécriture de liens .md est active, contrÎleur de panneau doc si le layout three-col est utilisé.
Posture d'intégration minimale
- Enregistrer le bundle.
- Activer l'auto-mapping Doctrine (ou mapper le namespace des entités).
- Déclarer
resolve_target_entitiespourCmsUserInterface. - Fournir les templates de base (
html.twig,cms/index.html.twig). - Configurer les locales activées et par défaut.
- Optionnellement configurer
tlr_symfony.multisiteetvitrine_allowed_routes. - Jouer
tlr:site:ensure-defaultau déploiement pour garantir ℠1 site.
Dépendances
Runtime : doctrine/doctrine-bundle, doctrine/orm, league/commonmark, psr/log, composants Symfony (config, dependency-injection, doctrine-bridge, event-dispatcher, form, http-foundation, http-kernel, options-resolver, routing, string, validator), un moteur de coloration syntaxique, twig/twig.
Dépendances de framework supposées mais non déclarées (fournies par l'app) : symfony/framework-bundle (contrÎleurs AbstractController), un composant de sécurité (ROLE_ADMIN), symfony/twig-bundle, un systÚme de session (i18n), symfony/console (commande de seed).
Absences notables : aucune suite de tests déclarée dans le bundle, aucune migration Doctrine, aucun asset (Bootstrap, Bootstrap Icons, Turbo, Stimulus référencés dans les templates mais fournis par l'app).