Skip to content

🎼 Symfony — du débutant au senior

Parcours structuré pour maîtriser Symfony (5 / 6 / 7) comme un ninja, du HttpKernel à API Platform, en passant par Doctrine, Messenger et le profiler.

Ce parcours ne vise pas à te faire « connaître Symfony ». Il vise à te faire raisonner comme l'équipe qui a conçu Symfony : comprendre pourquoi chaque brique existe, quels compromis elle encode, et comment elle se comporte sous charge, en production, quand ça casse à 3h du matin.


🧭 Le modèle mental : Symfony est un pipeline, pas un framework

La plus grosse erreur d'un dev qui passe de 7 ans d'XP « junior/medior » à senior sur Symfony, c'est de voir Symfony comme une boîte à magie (annotations, autowiring, « ça marche »). Un senior voit une seule chose :

Symfony transforme une Request en Response. Point. Tout le reste — controllers, Doctrine, security, Twig — sont des plugins branchés sur ce pipeline via des événements et un container de services.

Si tu retiens UNE phrase de ce parcours, c'est celle-là. Tout le code que tu écriras se branche quelque part sur ce pipeline.

        ┌─────────────────────────────────────────────────────────────┐
        │                        HttpKernel                            │
        │                                                              │
 Request│  kernel.request ──► kernel.controller ──► [ TON CONTROLLER ] │
   ──────┼──►  (routing,        (resolve args)            │            │
        │   firewall, CSRF)                               ▼            │
        │                                          renvoie Response    │
        │                                          OU n'importe quoi   │
        │                                               │              │
        │  kernel.response ◄── kernel.view ◄────────────┘              │
        │  (headers, cookies)   (transforme en Response si besoin)     │
        └──────────────────────────────┬──────────────────────────────┘

                                  Response ──────► client

Les deux invariants qui font tout tenir :

ConceptCe qu'un junior croitCe qu'un senior sait
Container DI« un truc qui crée mes objets »Un graphe compilé au build, pas au runtime. En prod le container est figé dans du PHP généré. L'autowiring n'existe plus à l'exécution.
Events« des hooks optionnels »Le cœur même du kernel. Security, le profiler, l'exception handling, la sérialisation API : tout est un EventListener/EventSubscriber.
Bundles« des plugins tiers »Des modules qui étendent le container au moment de la compilation (via Extension/CompilerPass).
Environnements« dev vs prod »Des jeux de config compilés séparément. Le container prod ≠ container dev. C'est pour ça que cache:clear existe.

Le détail qui sépare le senior : la compilation du container

php
// Ce que tu écris (déclaratif, autowired)
final class InvoiceController extends AbstractController
{
    public function __construct(private readonly InvoiceRepository $repo) {}
}

Au premier cache:warmup (ou première requête en dev), Symfony résout tout le graphe et génère un fichier PHP du genre var/cache/prod/Container*/getInvoiceControllerService.php. En production, instancier ton controller = exécuter du PHP plat sans réflexion. L'autowiring a un coût nul au runtime — c'est un compromis « compile-time cost vs runtime speed » qu'un senior sait expliquer en entretien.

Corollaire opérationnel : ne jamais écrire dans var/cache/ à chaud en prod, et toujours cache:clear/cache:warmup dans le build de déploiement, pas sur le serveur de prod sous trafic.


🗺️ Comment lire ce parcours (7 ans d'XP → senior)

Tu n'as pas besoin de tout lire dans l'ordre, mais tu as besoin de la bonne carte de dépendances entre les concepts :

Foundations (DI + HttpKernel)

        ├──► HTTP layer ──► Security (firewall = un listener sur kernel.request)
        │         │
        │         └──► Data layer (Doctrine) ──► API & Production

        └──► Async & Events ──► (Messenger s'appuie sur DI + serializer)
  • Ne saute pas le Niveau 1. 80 % des bugs « bizarres » (service nul, mauvaise instance injectée, config ignorée) viennent d'une incompréhension du container et des environnements.
  • Security se comprend mieux après le HTTP layer : un firewall n'est qu'un EventSubscriber sur kernel.request qui peut court-circuiter le pipeline.
  • Messenger et les Events partagent la même mécanique : un message dispatché, c'est conceptuellement le même pattern qu'un event, mais asynchrone et persistable.

Niveau 1 — Foundations

HttpKernel, container de services, bundles, routing — la mécanique interne.

Pourquoi ça compte : c'est la couche que les juniors ignorent et que les seniors maîtrisent. Le kernel.request event, l'argument resolver, et le container compilé sont le socle de tout le reste.

Niveau 2 — HTTP layer

Controllers, Request/Response, Twig, forms, validation.

Idiome 6.4/7.x à retenir : controllers en attributs PHP 8, injection par signature de méthode (#[MapRequestPayload], #[MapQueryString]), et final partout.

php
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/invoices', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateInvoiceDto $dto, // désérialisé + validé automatiquement
): JsonResponse {
    // $dto est déjà typé et validé : pas de $request->request->get(...) à la main
    return new JsonResponse(/* ... */, 201);
}

Niveau 3 — Data layer

Doctrine ORM, migrations, fixtures, transactions, repository.

Le piège senior : Doctrine est un Unit of Work, pas un mapper requête→ligne. Comprendre l'identity map, le lazy loading et le problème N+1 vaut plus que connaître 100 méthodes du QueryBuilder.

php
// Entité moderne : typage strict, enum backé, readonly là où c'est possible
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
class Invoice
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(enumType: InvoiceStatus::class)]
    private InvoiceStatus $status = InvoiceStatus::Draft;

    #[ORM\Column]
    private readonly \DateTimeImmutable $createdAt;

    public function __construct() { $this->createdAt = new \DateTimeImmutable(); }
}

enum InvoiceStatus: string
{
    case Draft = 'draft';
    case Sent = 'sent';
    case Paid = 'paid';
}

Niveau 4 — Security

Authentication, authorization, voters, JWT, CSRF.

Modèle mental : authentication (« qui es-tu ? ») et authorization (« as-tu le droit ? ») sont deux étapes distinctes. Le nouveau système d'authenticators (Symfony 5.3+, par défaut en 6/7) remplace l'ancien Guard. Pour la logique métier d'accès → toujours un Voter, jamais un if ($user->getRole() === ...) dans le controller.

Niveau 5 — Async & Events

Messenger, events/listeners, console commands, workflows.

Niveau staff : Messenger transforme ton monolithe en système distribué graduellement. Même MessageBusInterface, transport sync en dev → doctrine/amqp/redis en prod, sans toucher au code métier. Maîtrise les retries, le failure transport, et l'idempotence des handlers — c'est là que se jouent les vrais incidents de prod.

Niveau 6 — API & Production

API Platform, serializer, testing, profiler, monolog, perf, deployment, versions.

Ce qui distingue la prod du tuto : observabilité (Monolog + processors + correlation id), cache HTTP (ESI, Cache-Control, reverse proxy/Varnish), OPcache + preload, et une stratégie de déploiement zero-downtime.


⚡ Les compromis que Symfony encode (et qu'on te demandera en entretien)

Décision de SymfonyCe que tu gagnesCe que tu paiesQuand ça mord
Container compiléruntime ultra-rapide, validation au buildrebuild nécessaire à chaque changement de configdéploiements, cache:clear oublié
Tout passe par des eventsextensibilité totale, découplageflux d'exécution implicite, debugging non-linéaire« pourquoi ce listener s'exécute avant l'autre ? » → priorités
Doctrine (Unit of Work)pas de SQL à la main, cohérence transactionnelleN+1, hydratation coûteuse, magie du flushlistings, exports massifs, API à fort trafic
Autowiringzéro boilerplate de câblage« action à distance », ambiguïté sur les interfacesplusieurs implémentations d'une même interface
Full-stack vs microbatteries incluses, cohérencepoids, courbe d'apprentissagemicroservices, lambdas, edge

Un senior ne récite pas ces lignes : il sait dans quelle colonne se situe le problème qu'on lui décrit.


🔬 Failure modes que tout senior Symfony a déjà vécus

  • N+1 silencieux : une boucle Twig sur une collection lazy déclenche 1 requête par item. Détection : Web Profiler → onglet Doctrine, compte des requêtes. Fix : JOIN/fetch eager ciblé ou setFetchMode.
  • Le service injecté est le mauvais : deux implémentations d'une interface, autowiring qui en choisit une arbitrairement. Fix : alias explicite ou #[Autowire]/#[Target].
  • Config qui « ne prend pas » : tu édites un YAML, rien ne change → cache non vidé, ou mauvais environnement chargé. Fix : bin/console debug:container / debug:config.
  • Memory leak en worker Messenger : Doctrine garde tout en identity map. Fix : --limit, --memory-limit, ou EntityManager::clear() entre messages.
  • Requête qui timeout sous charge : pas de cache HTTP, hydratation d'objets là où un DTO/array hydration suffirait.

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans un vrai projet Symfony 7.x (symfony new ou composer create-project symfony/skeleton).

Exercice 1 — Trace le pipeline (implémenter)

Objectif : prouver, code à l'appui, que tu comprends le cycle Request → Response.

Écris un EventSubscriber qui logge un message à chaque étape (kernel.request, kernel.controller, kernel.controller_arguments, kernel.view, kernel.response, kernel.terminate) avec le timestamp en microsecondes.

Indice/Solution : implémente EventSubscriberInterface, retourne tous ces noms d'événements dans getSubscribedEvents(), et observe l'ordre réel dans bin/console debug:event-dispatcher. Constate que terminate s'exécute après l'envoi de la réponse au client.

Exercice 2 — Un service métier proprement câblé (implémenter)

Objectif : maîtriser DI, interfaces et autowiring ambigu.

Crée une interface PriceCalculatorInterface avec deux implémentations (StandardPriceCalculator, BlackFridayPriceCalculator). Injecte la bonne selon le contexte sans new.

Indice/Solution : alias par défaut dans services.yaml, puis #[Autowire(service: ...)] ou #[Target('blackFriday')] sur le paramètre qui doit recevoir l'autre. Vérifie avec debug:container PriceCalculatorInterface.

Exercice 3 — Tuer un N+1 (durcir prod)

Objectif : diagnostiquer et corriger un problème de perf Doctrine réaliste.

Crée Author 1—N Book, une page listant 50 auteurs avec leurs livres. Mesure le nombre de requêtes dans le profiler, puis ramène-le à 1–2.

Indice/Solution : le naïf fait 1 + 50 requêtes. Fix : ->leftJoin('a.books', 'b')->addSelect('b') dans le repository, ou hydratation partielle/DTO. Compare le temps avant/après dans l'onglet Performance.

Exercice 4 — Messenger production-grade (durcir prod)

Objectif : un traitement asynchrone fiable, retryable, idempotent.

Passe l'envoi d'email de confirmation en asynchrone via Messenger (transport doctrine). Configure 3 retries avec backoff, un failure_transport, et rends le handler idempotent (rejouer le message 2× n'envoie pas 2 emails).

Indice/Solution : messenger.yamlretry_strategy (max_retries, multiplier, delay) + failure_transport. Idempotence : table de déduplication (clé = id du message) ou flag persisté sur l'entité, vérifié au début du handler. Lance messenger:consume async -vv et messenger:failed:show.

Exercice 5 — Sécurise puis casse (break-then-fix)

Objectif : comprendre l'autorisation par Voter et une faille IDOR.

Expose GET /invoices/{id} qui retourne une facture. Volontairement, ne vérifie pas le propriétaire → reproduis l'IDOR (l'utilisateur A lit la facture de B). Puis corrige avec un Voter.

Indice/Solution : la faille = findById sans contrôle d'ownership. Fix : #[IsGranted('VIEW', 'invoice')] + un Voter qui compare $invoice->getOwner() à $token->getUser(). Écris un test fonctionnel qui prouve qu'un 403 est renvoyé pour le mauvais utilisateur.

Exercice 6 — Container compilé : casse le build (break-then-fix)

Objectif : internaliser que le container est compilé, pas dynamique.

Crée un CompilerPass qui collecte tous les services taggés app.report_exporter dans un registre. Puis introduis une dépendance circulaire entre deux services et observe l'échec à la compilation, pas au runtime.

Indice/Solution : implémente CompilerPassInterface, enregistre-le dans le Kernel (build()), utilise findTaggedServiceIds(). La dépendance circulaire lève une ServiceCircularReferenceException au cache:warmup — preuve que la validation est compile-time. Fix : casser le cycle via une factory ou un setter injection / lazy.


🎤 En entretien

Q : Décris ce qui se passe entre une Request HTTP qui arrive et la Response renvoyée, en termes Symfony. R : HttpKernel::handle() dispatche kernel.request (où le routing et le firewall opèrent et peuvent court-circuiter), résout le controller, dispatche kernel.controller puis kernel.controller_arguments, exécute le controller ; s'il ne renvoie pas une Response, kernel.view la fabrique ; enfin kernel.response (mutation headers/cookies) puis l'envoi, et kernel.terminate pour le travail post-réponse.

Q : Pourquoi le container de services est-il compilé, et quelles conséquences en prod ? R : Pour annuler le coût de l'autowiring/réflexion au runtime — le graphe est figé dans du PHP généré dans var/cache. Conséquence : tout changement de config exige un cache:clear/warmup, et ce warmup doit se faire au build de déploiement, pas à chaud sous trafic.

Q : Comment éviter et détecter un problème N+1 avec Doctrine ? R : Détection via le Web Profiler (compteur de requêtes Doctrine) ou doctrine:query:sql / le logger SQL. Évitement : JOIN + addSelect pour fetch eager ciblé, hydratation en DTO/array pour les listings en lecture seule, et éviter le lazy loading dans les boucles de templates.

Q : Voter vs #[IsGranted] vs vérif inline dans le controller — quand quoi ? R : La logique d'autorisation métier (ownership, état d'une ressource) vit dans un Voter, centralisé et testable. #[IsGranted] est le point d'entrée déclaratif qui appelle l'AccessDecisionManager (donc tes Voters). Un if inline dans le controller est un anti-pattern : non réutilisable, non testable isolément, et il disperse les règles de sécurité.


🎯 Definition of "Senior" sur Symfony

Tu es senior sur Symfony quand tu peux, sans documentation ouverte :

  1. Dessiner le pipeline HttpKernel et nommer chaque événement + son rôle.
  2. Expliquer pourquoi un service injecté n'est pas celui attendu, et le corriger.
  3. Diagnostiquer un problème de perf via le profiler et le réduire à une cause (N+1, hydratation, cache absent).
  4. Concevoir un traitement asynchrone Messenger fiable (retry, idempotence, failure transport).
  5. Justifier un choix de sécurité (authenticator, voter, firewall) face à un scénario d'attaque concret.
  6. Mener un déploiement zero-downtime avec OPcache/preload et warmup au build.

Commence par le Niveau 1 — Architecture & HttpKernel. Tout le reste s'y branche.

Bibliothèque tech perso — Achref