guide/patterns-symfony.md

Patterns Symfony — organisation et bonnes pratiques

Ce guide rassemble des patterns Symfony réutilisables pour organiser une application maintenable et extensible. Il s'adresse à un développeur qui connaît déjà Symfony et cherche des façons de structurer son code. Chaque section décrit le pattern, quand l'employer, et le squelette de code correspondant.


Architecture plugin — Interface + AutoconfigureTag + AutowireIterator

Le pattern le plus rentable pour rendre une application extensible : ajouter un comportement sans toucher au contrôleur ni à la configuration, simplement en créant une classe.

Principe

  1. Une interface déclare le contrat et porte #[AutoconfigureTag] (auto-enregistrement dans le conteneur DI).
  2. Une registry collecte toutes les implémentations via #[AutowireIterator].
  3. Le code consommateur appelle la registry, jamais les implémentations directement.

Exemple — sections de configuration extensibles

#[AutoconfigureTag]
interface ConfigSectionInterface
{
    public function key(): string;       // segment d'URL
    public function category(): string;  // groupe de navigation
    public function label(): string;
    public function priority(): int;     // ordre de tri
    public function handle(Request $request): Response;
}

La registry trie par priorité et regroupe par catégorie pour alimenter automatiquement la navigation :

final readonly class ConfigSectionRegistry
{
    public function __construct(
        #[AutowireIterator(ConfigSectionInterface::class)]
        iterable $sections,
    ) { /* tri + indexation */ }

    public function groupedByCategory(): array { /* catégorie => sections[] */ }
}

Ajouter une nouvelle section = créer une classe qui implémente l'interface. Zéro modification du contrôleur. Ce même schéma sert pour des collecteurs de données, des exporteurs, des filtres — toute famille de comportements interchangeables.

Quand l'utiliser : dès qu'une liste de comportements est vouée à grandir et que vous voulez éviter les switch/match centralisés ou les modifications répétées d'un contrôleur.


Symfony Messenger — traitements asynchrones

Déportez les tâches longues (imports, synchronisations, cycles batch) vers Messenger. Des workers consomment la file en arrière-plan.

final class SyncDailyMetricsMessage {}  // DTO, éventuellement vide : le type porte l'intention

#[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(TypeDuMessage $msg). Symfony relie automatiquement le handler au type. Gardez les messages sérialisables et légers (identifiants plutôt qu'objets lourds).


EventSubscriber — s'accrocher au cycle de vie

Contrôle d'accès sur la requête, avec priorité maîtrisée

final readonly class SiteAccessSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        // Priorité choisie pour s'exécuter après le routeur mais avant le firewall,
        // afin qu'un 404 prime sur une redirection de login.
        return [KernelEvents::REQUEST => [['onKernelRequest', 16]]];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        // Applique une logique d'accès selon le contexte de la requête.
    }
}

Point clé : la priorité d'un listener sur kernel.request n'est pas anodine. Positionnez-la consciemment par rapport aux listeners du framework (routeur, firewall) pour obtenir l'ordre d'exécution voulu.

Chaîner un traitement après un message Messenger

final class PostProcessSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [WorkerMessageHandledEvent::class => 'onMessageHandled'];
    }

    public function onMessageHandled(WorkerMessageHandledEvent $event): void
    {
        // Ne réagir qu'à un type de message précis.
        if (!$event->getEnvelope()->getMessage() instanceof RunCycleMessage) {
            return;
        }
        // Déclencher un traitement de suite dans le même process worker.
    }
}

Pattern : écouter WorkerMessageHandledEvent pour enchaîner un traitement dans le worker sans émettre un message supplémentaire.


Security — Authenticator d'API + Rate Limiter

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 : on rejette avant d'interroger la base.
        $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)])
            )
        );
    }
}

Bonnes pratiques illustrées :

  • Le token est hachĂ© cĂ´tĂ© base — jamais stockĂ© ni comparĂ© en clair.
  • Le rate limiting s'applique avant le lookup en base, pour ne pas requĂŞter sur un token invalide.
  • SelfValidatingPassport + UserBadge : pas de mot de passe, la closure de rĂ©solution valide l'identitĂ©.

Rediriger selon le rôle après connexion

final readonly class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        $route = in_array('ROLE_ADMIN', $token->getRoleNames(), true)
            ? 'admin.dashboard'
            : 'user.home';

        return new RedirectResponse($this->urls->generate($route));
    }
}

Un default_target_path unique envoie tout le monde au même endroit, ce qui provoque un 403 pour les rôles non autorisés. Un handler dédié règle la destination par rôle.


Twig Extensions — exposer de la logique métier aux templates

final class RoutesExtension extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [new TwigFunction('admin_routes', [$this, 'getAdminRoutes'])];
    }

    public function getAdminRoutes(): array
    {
        // Inspecte la RouteCollection, filtre par préfixe,
        // construit des libellés lisibles, retourne un tableau trié.
    }
}

Quand l'utiliser : pour éviter de la logique dans les templates. L'extension expose une fonction claire ; le template reste déclaratif.


Companion Entity — étendre une entité sans la modifier

Quand une entité provient d'un bundle tiers et ne peut pas être modifiée, ajoutez les champs dans une table séparée liée en OneToOne.

#[ORM\Entity]
#[ORM\Table(name: 'content_seo')]
class ContentSeo
{
    #[ORM\OneToOne(targetEntity: Content::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?Content $content = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $canonicalUrl = null;
}

onDelete: 'CASCADE' garantit que le companion suit le cycle de vie de l'entité principale. Vous étendez le modèle sans forker ni patcher le bundle.


Repository — encapsuler les requêtes

Donnez à chaque entité un repository dont les méthodes portent l'intention métier. Évitez de disperser des findBy(['status' => '...']) dans les contrôleurs.

$items = $this->items->findBy(
    ['status' => Item::STATUS_PROPOSED],
    ['processedAt' => 'ASC'],
    $batchLimit,
);

Des méthodes comme findFilteredPaginated() ou countByCategory() encapsulent les QueryBuilder. Les contrôleurs ne manipulent jamais de DQL directement : la logique de requête reste testable et réutilisable.


Console Commands — les opérations batch

Réservez les traitements batch (imports, synchronisations, génération de tokens) à des commandes console, déclenchées via bin/console ou un scheduler — jamais via un endpoint HTTP. Une opération lourde exposée en HTTP est un risque de timeout et de sécurité.


Conventions transverses

Classes finales et readonly par défaut

Marquez final readonly class dès qu'une classe ne mute pas après construction. Cela documente l'immuabilité et prévient les héritages non désirés. On l'omet si la classe a des setters ou doit être étendue.

Constructor property promotion

public function __construct(
    private readonly ValidationServiceInterface $validationService,
    private readonly ItemRepository $items,
    private readonly LoggerInterface $logger = new NullLogger(),
) {}

Un new NullLogger() en valeur par défaut rend la dépendance optionnelle sans passer par un ?LoggerInterface suivi d'un null check à chaque usage.

Injection par attribut

#[Autowire(service: 'limiter.api')]
private readonly RateLimiterFactory $apiLimiter,

#[AutowireIterator] et #[Autowire(service: '...')] câblent les dépendances directement sur le constructeur, sans configuration YAML/XML pour le câblage unitaire.

Constantes de statut sur l'entité

public const string STATUS_PENDING  = 'pending';
public const string STATUS_PROPOSED = 'proposed';
public const array STATUSES = [self::STATUS_PENDING, self::STATUS_PROPOSED];

Centralisez les valeurs de statut sur l'entité et réutilisez-les partout : validation, repositories, tests. Une seule source de vérité, pas de chaîne magique dupliquée.


Ă€ retenir

  • Interface + AutoconfigureTag + AutowireIterator : la colonne vertĂ©brale de l'extensibilitĂ©.
  • Messenger pour tout ce qui est long ou diffĂ©rĂ©.
  • EventSubscriber avec prioritĂ©s maĂ®trisĂ©es pour s'insĂ©rer proprement dans le cycle de vie.
  • SĂ©curitĂ© : hacher les secrets, limiter le dĂ©bit avant le lookup, rediriger par rĂ´le.
  • Doctrine : companion entities pour Ă©tendre sans forker, repositories pour encapsuler les requĂŞtes.
  • Conventions : final readonly, promotion de constructeur, injection par attribut, constantes de statut.

Assistant documentaire

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