Patterns PHP/Symfony — Telaria
Patterns extraits directement du code de production. Chaque section cite le fichier source. Pour l'architecture globale (bundles, BDD, auth) : voir
architecture-symfony.md.
Plugin architecture — Interface + AutoconfigureTag + AutowireIterator
Le pattern le plus répété dans l'écosystème. Permet d'ajouter un comportement sans toucher ni le contrôleur, ni la configuration — juste créer une classe.
Principe
- Une interface déclare le contrat +
#[AutoconfigureTag](auto-enregistrement DI). - Une registry collecte toutes les implémentations via
#[AutowireIterator]. - Le code consommateur appelle la registry, pas les implémentations directement.
Exemple 1 — Collecteurs de veille (CollectorInterface)
// tlr-codexia : Veille/Collector/CollectorInterface.php #[AutoconfigureTag] interface CollectorInterface { public function collect(VeilleSource $source): iterable; // iterable<CollectedItem> public function supports(): array; // ['rss', 'atom'] }
Ajouter un nouveau type de source = créer une classe HtmlCollector implements CollectorInterface. Zéro modification ailleurs.
Exemple 2 — Sections de configuration BO (ConfigSectionInterface)
// telaria-app : Admin/Config/ConfigSectionInterface.php #[AutoconfigureTag] interface ConfigSectionInterface { public function key(): string; // segment URL : /admin/config/{key} public function category(): string; // groupe nav gauche public function label(): string; public function icon(): string; // icĂ´ne Bootstrap-Icons public function priority(): int; // ordre de tri dans la nav public function handle(Request $request): Response; // rendu du panneau droit }
La registry trie par priorité et groupe par catégorie pour alimenter la navigation automatiquement :
// telaria-app : Admin/Config/ConfigSectionRegistry.php final readonly class ConfigSectionRegistry { public function __construct( #[AutowireIterator(ConfigSectionInterface::class)] iterable $sections, ) { /* tri + index */ } public function groupedByCategory(): array { /* catégorie => sections[] */ } }
Pourquoi ce pattern : navigation extensible sans modification du ConfigController. Chaque feature ajoute sa section en autonomie. Calqué sur le pattern Collector (référencé dans le docblock de l'interface).
Symfony Messenger — Messages asynchrones
Les tâches longues (ingest RAG, synchro métriques, cycle veille) passent par Messenger. Workers systemd consomment la file.
// tlr-codexia : Metrics/Message/SyncDailyMetricsMessage.php final class SyncDailyMetricsMessage {} // DTO vide — le type est le message // tlr-codexia : Metrics/Message/SyncDailyMetricsHandler.php #[AsMessageHandler] final readonly class SyncDailyMetricsHandler { public function __construct(private MetricsSyncService $sync) {} public function __invoke(SyncDailyMetricsMessage $message): void { $this->sync->sync(new \DateTimeImmutable('-5 days')); } }
Pattern constant : #[AsMessageHandler] + __invoke(MessageType $msg). Symfony relie automatiquement le handler au type de message.
EventSubscriber — Hooks sur le cycle de vie Symfony
SiteAccessSubscriber — cloisonnement par domaine
// tlr-symfony : EventSubscriber/SiteAccessSubscriber.php final readonly class SiteAccessSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { // Priorité 16 : après RouterListener (32), avant firewall (8) // → le 404 prime sur la redirection de login return [KernelEvents::REQUEST => [['onKernelRequest', 16]]]; } public function onKernelRequest(RequestEvent $event): void { // Sites vitrines (non-primaires) : whitelist de routes uniquement // Site primaire : application complète } }
Décision de priorité consciente : 16 positionné manuellement entre RouterListener et firewall pour que le 404 s'applique avant tout contrôle d'accès.
VeilleAutoValidateSubscriber — chaînage post-Messenger
// telaria-app : Veille/Validation/VeilleAutoValidateSubscriber.php final class VeilleAutoValidateSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [WorkerMessageHandledEvent::class => 'onMessageHandled']; } public function onMessageHandled(WorkerMessageHandledEvent $event): void { // N'intervient qu'après RunVeilleCycleMessage if (!$event->getEnvelope()->getMessage() instanceof RunVeilleCycleMessage) { return; } // Déclenche la validation IA des items proposed $this->runValidationBatch($prompt); } }
Pattern : écouter WorkerMessageHandledEvent pour chaîner un traitement dans le même process worker, sans créer de message supplémentaire.
Security — Authenticator API + Rate Limiter
// telaria-app : Security/APIAuthenticator.php class APIAuthenticator extends AbstractAuthenticator { public function __construct( #[Autowire(service: 'limiter.api')] private readonly RateLimiterFactory $apiLimiter, private readonly UserRepository $userRepository ) {} public function supports(Request $request): ?bool { return $request->headers->has('X-Auth-Token'); } public function authenticate(Request $request): Passport { // Rate limiting AVANT l'auth : rejette les bots avant d'interroger la BDD $limiter = $this->apiLimiter->create($request->getClientIp()); if (!$limiter->consume(1)->isAccepted()) { throw new TooManyRequestsHttpException(); } return new SelfValidatingPassport( new UserBadge($token, fn($id) => $this->userRepository->findOneBy(['apiToken' => hash('sha512', $id)]) ) ); } }
Points notables :
- Token haché en SHA-512 côté BDD — jamais le token brut.
- Rate limiting avant le lookup BDD (pas de scan sur token invalide).
SelfValidatingPassport: pas dePasswordCredentials, leUserBadgeclosure fait la validation.
LoginSuccessHandler — redirection par rôle
// telaria-app : Security/LoginSuccessHandler.php final readonly class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface { public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response { $route = in_array('ROLE_ADMIN', $token->getRoleNames(), true) ? 'admin.dashboard.show' : 'reader.index'; return new RedirectResponse($this->urls->generate($route)); } }
Sans ce handler, default_target_path envoyait tout le monde vers /admin → 403 pour les lecteurs non-admin.
Twig Extensions
Pattern standard pour exposer des fonctions Twig métier :
// telaria-app : Twig/AdminRoutesExtension.php final class AdminRoutesExtension extends AbstractExtension { public function getFunctions(): array { return [new TwigFunction('admin_routes', [$this, 'getAdminRoutes'])]; } public function getAdminRoutes(): array { // Inspecte la RouteCollection, filtre les routes 'admin.*', // construit un label lisible depuis le nom de route, // retourne un tableau trié alphabétiquement. } }
Extensions en production : AdminRoutesExtension, PageAttributesExtension, VeilleExtension (telaria-app) · AppExtension, CmsExtension, SiteExtension (tlr-symfony/tlr-codexia).
Companion Entity — extension sans modifier un bundle
Quand une entité d'un bundle (CmsContent dans tlr-symfony) ne peut pas être modifiée directement, on ajoute les champs dans une table séparée liée par OneToOne.
// telaria-app : Entity/CmsContentSeo.php #[ORM\Entity] #[ORM\Table(name: 'cms_content_seo')] class CmsContentSeo { #[ORM\OneToOne(targetEntity: CmsContent::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private ?CmsContent $content = null; #[ORM\Column(length: 255, nullable: true)] private ?string $canonicalUrl = null; }
onDelete: 'CASCADE' garantit la cohérence. Le companion suit le cycle de vie de l'entité principale.
Repository — Requêtes typées
Chaque entité a son repository Doctrine avec des méthodes métier nommées explicitement. Pas de findBy(['status' => 'proposed']) dispersé dans le code :
// Pattern VeilleItemRepository $items = $this->items->findBy( ['status' => VeilleItem::STATUS_PROPOSED], ['processedAt' => 'ASC'], $batchLimit, );
Les repositories findFilteredPaginated(), countByTheme(), findByItem() encapsulent les QueryBuilders — les contrôleurs ne manipulent jamais de DQL directement.
Console Commands
// telaria-app : Command/UserGenerateTokenCommand.php // telaria-app : Command/SyncSitesCommand.php // tlr-codexia : Command/VeilleIngestCommand.php // tlr-codexia : Command/VeilleProcessCommand.php // tlr-codexia : Command/MetricsSyncCommand.php
Commandes console pour les opérations batch (ingest, sync métriques, génération de token API). Toujours déclenchées via bin/console ou scheduler — jamais via un endpoint HTTP.
Patterns transverses
Readonly par défaut
Classes finales + readonly dès qu'une classe ne mute pas après construction : final readonly class. Omis si la classe a des setters ou est susceptible d'être étendue.
Constructor promotion
public function __construct( private readonly VeilleValidationServiceInterface $validationService, private readonly VeilleItemRepository $items, private readonly LoggerInterface $logger = new NullLogger(), ) {}
new NullLogger() comme défaut de paramètre = injection optionnelle sans ?LoggerInterface + null check.
Injection par attribut
#[Autowire(service: 'limiter.api')] private readonly RateLimiterFactory $apiLimiter,
#[AutowireIterator], #[Autowire(service: '...')] — pas de configuration XML/YAML pour le câblage unitaire.
Constantes de statut
public const string STATUS_PENDING = 'pending'; public const string STATUS_PROPOSED = 'proposed'; public const array STATUSES = [self::STATUS_PENDING, self::STATUS_PROPOSED, ...];
Constantes dans l'entité, utilisées partout (validation @Choice(choices: self::STATUSES), repositories, tests).
Voir aussi
architecture-symfony.md— vue bundles, Doctrine, auth, Messengerbdd/schema.md— tables et entitéstests.md— couverture et stratégie de testtutos/ia/serveur-mcp-symfony.md— bundle MCP (pattern Tenant/Scope/Audit)