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.
À lire aussi : Les DTOs : arrêtez de transformer vos données en kebab
Les trois rôles ADR et leur traduction en Symfony
| 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) |
La règle des trois frontières
Un test rapide de votre découpage : si votre
__invokecontient une requête SQL ou un vrai calcul métier, une frontière a sauté. L'Action coordonne, le Domain travaille, le Responder répond. Rien de plus dans le contrôleur.
À lire aussi : 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.php
Le 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.
À lire aussi : 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.
Un fichier par action, et on s'y retrouve
Avec un seul
__invokepar classe et le suffixe*Action, chercher du code redevient trivial : Cmd+P,PostAction, et vous y êtes. Fini le scroll dans un fichier de mille lignes pour retrouver la bonne méthode.
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.
Quand le Domain fuit dans l'Action
Un contrôleur qui appelle un repository, c'est le Domain qui déborde sur l'Action. Ici, deptrac casse le build. Sans un garde-fou de ce genre, personne ne le voit passer, et le contrôleur regrossit jusqu'au God Controller du départ.
# deptrac.yaml (extrait simplifié)
ruleset:
Controller:
- Service # autorisé
# Repository absent de la liste : donc interdit
Service:
- Repository # c'est au service de parler au repository
Le 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
Dans ADR, que recouvre le « D » (Domain) ?
-
La logique métier : services, entités, repositories
-
Le contrôleur qui reçoit la requête
-
Le template qui produit le HTML
L'Action coordonne, le Domain fait le travail métier et parle aux données, le Responder construit la réponse.
Pourquoi un contrôleur ADR n'appelle-t-il pas un repository directement ?
-
L'accès aux données relève du Domain : on le délègue à un service (ici deptrac casse le build sinon)
-
Parce que c'est plus lent
-
Parce que Doctrine l'interdit techniquement
La frontière Action / Domain veut que le contrôleur délègue. deptrac transforme cette convention en règle vérifiée au build.
Que fait <code>name: self::class</code> sur une route ?
-
Il nomme la route avec le nom complet de la classe, unique par défaut
-
Il charge la classe au démarrage
-
Il définit le chemin de l'URL
Le nom complet de la classe comme nom de route évite les collisions et se référence proprement, par exemple dans
redirectToRoute(CategoriesAction::class).
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).