Un contrôleur Symfony commence toujours propre. Puis on lui ajoute une méthode, puis une autre. Six mois plus tard, ProductController contient index, new, edit, delete, export, pèse 500 lignes, et injecte quinze dépendances dans son constructeur, dont la moitié ne servent qu'à une seule méthode. On appelle ça un God Controller, et personne ne l'a décidé : il a poussé tout seul.
Il existe une autre voie, alignée sur le protocole HTTP lui-même : le pattern ADR, pour Action-Domain-Responder. Ce billet va au bout des trois lettres, pas seulement de la première : ce que fait chaque rôle, comment Symfony les traduit, à quoi ça ressemble sur du code réel en production, et surtout quand l'ADR n'est pas la bonne réponse.
Le problème : le God Controller
Historiquement, on regroupe dans une classe toutes les actions liées à une entité. ProductController gère donc la liste, la création, l'édition, la suppression. Le souci n'est pas l'idée, c'est ce qu'elle devient en grossissant.
Un constructeur qui injecte quinze services quand une requête donnée n'en utilise que deux. Des propriétés privées partagées entre des méthodes qui n'ont rien à voir. Un fichier où l'on scrolle pour retrouver la bonne méthode, avec la peur de casser delete en modifiant edit. La classe ne rend plus un service clair : elle est devenue un tiroir dans lequel on entasse.
ADR : trois rôles, pas seulement un
Le pattern a été formalisé par Paul M. Jones comme une adaptation de MVC pensée pour le web. Il découpe une interaction HTTP en trois responsabilités distinctes.
- Action : reçoit la requête, coordonne, retourne une réponse. Elle ne calcule rien elle-même.
- Domain : la logique métier et les données. Services, entités, repositories. C'est là que le travail réel se fait.
- Responder : construit la réponse. Une page HTML, du JSON, un flux XML, une image.
La plupart des articles sur l'ADR s'arrêtent à l'Action et oublient le D et le R. C'est dommage, car c'est justement la répartition entre ces trois rôles qui fait la valeur du pattern. Voici comment Symfony les incarne.
Pour aller plus loin
Les DTOs : arrêtez de transformer vos données en kebab
| Rôle ADR | Sa responsabilité | En Symfony |
|---|---|---|
| Action | reçoit la requête, coordonne | un contrôleur invokable (__invoke) |
| Domain | logique métier et accès aux données | Services, Entities, Repositories |
| Responder | construit la réponse | #[Template] (HTML), un objet Response (JSON, XML, image) |
Sur le même sujet
Symfony 8.1 : vos Commands passent en mode Controller
Une classe, une route, une action
L'Action, en Symfony, c'est un contrôleur qui n'a qu'une seule méthode : __invoke. Une classe, une route, une action. Au lieu d'un PostController fourre-tout, on a un fichier par intention : afficher un billet, lister une catégorie, servir un flux RSS.
Sur ce blog, les contrôleurs suivent tous la même convention : suffixe *Action, un seul __invoke, rangés par sous-domaine.
src/Controller/
├── Content/
│ ├── PostAction.php
│ ├── CategoryAction.php
│ └── HomeAction.php
├── Feed/
│ └── FeedAction.php
├── Security/
│ └── LoginAction.php
└── Seo/
└── SitemapAction.phpLe dossier ne se lit plus comme une liste de concepts techniques, mais comme la liste des choses que l'application sait faire. Vous cherchez le code qui affiche un billet ? C'est PostAction. Vous ouvrez le fichier, et tout ce qu'il contient ne concerne que ça.
Où vit src/Controller dans le projet
Anatomie d’un projet Symfony
Cas réel : PostAction
Voici le contrôleur qui affiche un billet, extrait du code de ce blog (simplifié pour la lecture) :
namespace App\Controller\Content;
use App\Config\HttpCacheConfig;
use App\Entity\Content\Post;
use App\Service\Content\Block\BlockRenderer;
use App\Service\Content\TableOfContents\TableOfContentsExtractor;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;
final class PostAction extends AbstractController
{
public function __construct(
private readonly BlockRenderer $blockRenderer,
private readonly TableOfContentsExtractor $tableOfContentsExtractor,
) {}
#[Cache(
maxage: HttpCacheConfig::POST_MAXAGE,
smaxage: HttpCacheConfig::POST_SMAXAGE,
public: true,
mustRevalidate: true,
)]
#[Route(
path: '/billets/{slug}',
name: self::class,
requirements: ['slug' => '^[a-z0-9-]+$'],
methods: ['GET'],
)]
#[Template(template: self::class . '.html.twig')]
public function __invoke(
#[MapEntity(expr: 'repository.findPublishedBySlug(slug)')]
Post $post,
): array {
$html = $this->blockRenderer->render($post->blocks);
return [
'post' => $post,
'tocHeadings' => $this->tableOfContentsExtractor->extract($html),
];
}
}Le corps de la méthode ne contient aucune plomberie de framework. Tout ce qui relève du HTTP est monté dans des attributs déclaratifs.
#[Route]définit l'URL. Regardezname: self::class: le nom de la route est le nom complet de la classe, donc unique par construction. Plus jamais de collision de nom de route.#[Cache]pose les en-têtes de cache HTTP. Les durées vivent dans une config dédiée plutôt qu'en dur (le vrai fichier ajoute même un validateurLast-Modifiedpour répondre 304 sur une requête conditionnelle).#[Template]fait le rendu Twig. Le contrôleur retourne un simple tableau de variables, Symfony s'occupe de la vue.#[MapEntity]prend leslugde l'URL, appellefindPublishedBySlugsur le repository et lève une 404 si rien ne sort. Le contrôleur ne cherche pas le billet, il le reçoit déjà résolu.
Le Domain, et pourquoi deptrac le surveille
Le rôle le plus discret de l'ADR est aussi le plus important. Le contrôleur ne fait pas le travail métier, il le délègue. Regardez à nouveau PostAction : il ne parle pas à la base de données, il appelle BlockRenderer et TableOfContentsExtractor. Deux services. La donnée, elle, est amenée par #[MapEntity].
Cette frontière est facile à énoncer et facile à trahir. Un jour pressé, on injecte un repository dans un contrôleur et on écrit une requête directement là. Le Domain vient de fuir dans l'Action. Sur ce blog, ce n'est pas qu'une règle de style : deptrac fait échouer le build si un contrôleur importe un repository.
# deptrac.yaml (extrait simplifié)
ruleset:
Controller:
- Service # autorisé
# Repository absent de la liste : donc interdit
Service:
- Repository # c'est au service de parler au repositoryLe contrôleur qui liste une catégorie applique la même discipline. Il compte et récupère ses billets via des services, jamais via le repository, et il gère une règle métier au passage :
public function __invoke(
#[MapEntity(expr: 'repository.findOneBy({"slug": slug, "status": "online"})')]
Category $category,
#[MapQueryParameter(name: 'page')]
?int $page = 1,
): Response|array {
$totalPosts = $this->postStatisticsProvider->countByCategory($category);
// Règle métier : pas de page pour une catégorie vide
if ($totalPosts === 0) {
return $this->redirectToRoute(CategoriesAction::class);
}
// ... pagination, puis délégation au service
$posts = $this->postService->findPublishedByCategory($category, self::POSTS_PER_PAGE, $offset);
return ['category' => $category, 'posts' => $posts, /* ... */];
}Le contrôleur reste mince : il vérifie une condition, redirige si besoin, délègue la recherche. Le comptage passe par PostStatisticsProvider, la récupération par PostService. Le repository n'apparaît jamais.
Le Responder, absorbé par Symfony
Dans l'ADR d'origine, le Responder est un objet séparé, chargé de fabriquer la réponse. Symfony l'a fait disparaître dans le décor, et c'est une bonne nouvelle. Quand vous retournez un tableau avec #[Template], le Responder, c'est le moteur Twig. Vous ne l'écrivez pas, mais il est bien là.
Pour tout ce qui n'est pas du HTML, le Responder redevient visible : le contrôleur retourne un objet Response qu'il construit lui-même. Le flux RSS du blog en est un bon exemple :
#[Route('/feed.xml', name: 'feed_rss', defaults: ['_format' => 'xml'], methods: ['GET'])]
#[Route('/atom.xml', name: 'feed_atom', defaults: ['_format' => 'atom'], methods: ['GET'])]
public function __invoke(Request $request): Response
{
$posts = $this->postService->findLatestPublished(limit: 20);
$response = new Response();
$response->setLastModified($lastBuildDate);
$response->setPublic();
if ($response->isNotModified($request)) {
return $response; // 304, on ne régénère pas le flux
}
$template = 'atom' === $format ? 'feed/atom.xml.twig' : 'feed/rss.xml.twig';
$response->headers->set('Content-Type', 'application/rss+xml; charset=UTF-8');
return $this->render($template, ['posts' => $posts, /* ... */], $response);
}Même structure que PostAction : reçoit la requête, délègue au Domain (PostService), construit la réponse. Seul le Responder change de forme, un Response XML au lieu d'un tableau HTML. Le Responder que vous n'écrivez pas reste un Responder ; Symfony l'a simplement rendu déclaratif.
Ce que ce billet ne dit pas
On a détaillé les trois rôles et leur traduction Symfony, pas la conception interne de chacun. L'organisation en couches du Domain, les DTO de sortie, le Responder poussé jusqu'à l'objet dédié (comme le fait API Platform), ou encore les tests d'un contrôleur invokable : autant de sujets qui méritent leur propre billet. Ici, l'objectif était de sortir du God Controller sans tomber dans le culte inverse.
Un dossier Controller qui se lit comme un sommaire
L'architecture n'est pas là pour faire joli, elle est là pour réduire la charge mentale. L'ADR transforme votre dossier src/Controller en table des matières des fonctionnalités : une ligne par chose que l'application sait faire, au lieu d'une poignée de classes fourre-tout où tout se mélange.
La prochaine fois que vous ouvrez un contrôleur et que vous devez scroller pour trouver la bonne méthode, posez-vous la question : est-ce que ce fichier fait une chose, ou est-ce qu'il en fait dix ?
ADR : les trois lettres
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).