🎼 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
RequestenResponse. 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 ──────► clientLes deux invariants qui font tout tenir :
| Concept | Ce qu'un junior croit | Ce 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
// 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 toujourscache:clear/cache:warmupdans 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
EventSubscribersurkernel.requestqui 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.
- 01 — Architecture & HttpKernel
- 02 — Dependency Injection (container)
- 03 — Bundles
- 04 — Configuration (YAML/PHP/attributes)
- 05 — Routing
- 06 — Environments (dev/prod/test)
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.
- 01 — Controllers
- 02 — Request & Response
- 03 — Twig templates
- 04 — Forms
- 05 — Validation
- 06 — Sessions & flash
Idiome 6.4/7.x à retenir : controllers en attributs PHP 8, injection par signature de méthode (#[MapRequestPayload], #[MapQueryString]), et final partout.
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.
// 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.
- 01 — API Platform
- 02 — Serializer
- 03 — Testing (PHPUnit)
- 04 — Profiler & debug
- 05 — Monolog
- 06 — Performance & cache
- 07 — Deployment
- 08 — Versions 5 → 7
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 Symfony | Ce que tu gagnes | Ce que tu paies | Quand ça mord |
|---|---|---|---|
| Container compilé | runtime ultra-rapide, validation au build | rebuild nécessaire à chaque changement de config | déploiements, cache:clear oublié |
| Tout passe par des events | extensibilité totale, découplage | flux 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 transactionnelle | N+1, hydratation coûteuse, magie du flush | listings, exports massifs, API à fort trafic |
| Autowiring | zéro boilerplate de câblage | « action à distance », ambiguïté sur les interfaces | plusieurs implémentations d'une même interface |
| Full-stack vs micro | batteries incluses, cohérence | poids, courbe d'apprentissage | microservices, 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 eagerciblé ousetFetchMode. - 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, ouEntityManager::clear()entre messages. - Requête qui timeout sous charge : pas de cache HTTP, hydratation d'objets là où un DTO/
arrayhydration suffirait.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans un vrai projet Symfony 7.x (
symfony newoucomposer 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.yaml → retry_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 :
- Dessiner le pipeline HttpKernel et nommer chaque événement + son rôle.
- Expliquer pourquoi un service injecté n'est pas celui attendu, et le corriger.
- Diagnostiquer un problème de perf via le profiler et le réduire à une cause (N+1, hydratation, cache absent).
- Concevoir un traitement asynchrone Messenger fiable (retry, idempotence, failure transport).
- Justifier un choix de sécurité (authenticator, voter, firewall) face à un scénario d'attaque concret.
- 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.