03-comment-je-travaille/guides/patterns-symfony.md

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

  1. Une interface déclare le contrat + #[AutoconfigureTag] (auto-enregistrement DI).
  2. Une registry collecte toutes les implémentations via #[AutowireIterator].
  3. 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 de PasswordCredentials, le UserBadge closure 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

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.

Loading…
Loading the web debug toolbar…
Attempt #