Architecturer une application Symfony pour la production

Du conteneur de services à la sécurité en passant par Doctrine et les files de messages : les décisions d’architecture qui tiennent à l’échelle, sans sur-ingénierie.

Organiser le code par modules métier

Une application Symfony n’a pas besoin de DDD pour être lisible : organisez le code par modules métier (Account, Quiz, Contact…) avec leurs propres contrôleurs, DTO, entités, services et repositories. La dépendance va des modules vers les infrastructures partagées, jamais l’inverse. Cette structure rend les frontières visibles, facilite les tests et évite les classes fourre-tout.

src/
  Account/
    Controller/
    Dto/
    Entity/
    Repository/
    Service/
  Quiz/
    State/
    Service/
  Shared/
    EventListener/
    Repository/

Contrôleurs minces, services orientés cas d’usage

Un contrôleur valide l’entrée, délègue à un service nommé d’après l’action utilisateur (`QuizAnswerService`) et transforme le résultat en réponse. La logique métier vit dans les services et les entités, jamais dans les contrôleurs ni dans les métadonnées API. Résultat : chaque cas d’usage est testable sans HTTP et les contrôleurs deviennent triviaux à relire.

#[Route('/api/quiz-sessions/{id}/answers', methods: ['POST'])]
public function answer(
    QuizSession $session,
    #[MapRequestPayload] AnswerPayload $payload,
    QuizAnswerService $service,
): JsonResponse {
    $result = $service->answer($session, $payload);
    return $this->json($result, Response::HTTP_CREATED);
}

Maîtriser le conteneur : autowiring, tags et compiler passes

L’autowiring couvre la majorité des cas ; les tags organisent les collections de services (event subscribers, validators, data transformers) sans code de câblage manuel. Les compiler passes modifient les définitions au moment de la compilation et restent un outil de dernier recours : préférez des factories et des tags explicites. Un service public global est presque toujours un anti-pattern.

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'

Doctrine à l’échelle : lectures, écritures et index

À l’échelle, les entités complètes ne sont pas toujours la bonne réponse : pour les listes et les tableaux de bord, des requêtes hydratées en tableau ou des DTO SQL natifs coûtent moins cher. Écrivez en transactions courtes via `transactional()`, indexez selon les prédicats réels et évitez les chargements paresseux en boucle (N+1). Un profil de requêtes (profiler, logs) guide mieux qu’une intuition.

// Éviter le N+1 : jointure + hydratation DTO
$query = $entityManager->createQuery(
    'SELECT NEW App\Quiz\Dto\SessionSummary(s.id, s.score, u.email)
     FROM App\Quiz\Entity\QuizSession s
     JOIN s.user u
     WHERE s.tenant = :tenant'
);

Travail asynchrone : Messenger et files de messages

Les opérations longues ou non bloquantes (emails, import, notification) doivent quitter le cycle requête-réponse. Messenger serialise des messages, les transporte (sync en dev, RabbitMQ ou Redis en production) et les traite dans des workers. Chaque message est un contrat : versionnez-le, rendez son traitement idempotent et gérez les échecs par retry avec backoff, puis par échec final journalisé.

final class SendQuizResultNotification
{
    public function __construct(
        public readonly int $sessionId,
        public readonly int $userId,
    ) {}
}

// config/packages/messenger.yaml
messenger:
    transports:
        async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
        'App\Quiz\Message\SendQuizResultNotification': async

Sécurité : routes, ownership et moindre privilège

Chaque route qui accède à une ressource doit vérifier l’authentification ET la propriété : un voter dédié charge l’entité et compare le propriétaire, l’opération API Platform déclare sa règle explicitement. Les zones d’administration sont protégées par un rôle dédié, jamais par l’absence de lien dans le menu. Ne stockez aucun secret dans le code : passez par la configuration Symfony et les variables d’environnement.

#[Route('/admin', name: 'admin')]
#[IsGranted('ROLE_ADMIN')]
final class AdminDashboardController extends AbstractController
{
    // Le rôle est vérifié avant l'exécution, pas par le frontend
}

Cache HTTP, applicatif et Doctrine : la bonne couche

La première couche de cache est HTTP : réponses publiques avec `Cache-Control` et validation ETag. En dessous, le cache applicatif (Redis, APCu) stocke les résultats de calculs coûteux avec une clé incluant le contexte (locale, page, tenant). Le cache de second niveau Doctrine ne sert que pour des données rarement modifiées. Ne jamais cacher une réponse personnalisée sans clé d’identité.

$cacheKey = sprintf('quiz.dashboard.%d.%s', $tenantId, $locale);
$dashboard = $cache->get($cacheKey, function (ItemInterface $item) use ($tenantId): array {
    $item->expiresAfter(300);
    return $this->dashboardBuilder->build($tenantId);
});

Tester l’architecture, pas seulement les cas heureux

Les tests d’intégration avec un vrai conteneur valident la configuration (services, routes, sécurité), les tests fonctionnels couvrent les cas d’erreur et les tests unitaires isolent la logique métier. Chaque règle de sécurité et chaque comportement de transaction mérite un test dédié. Une suite qui couvre les échecs protège mieux que des centaines de cas heureux.

public function test_owner_cannot_answer_another_users_session(): void
{
    $other = $this->createUser();
    $session = $this->createSession($other);

    $this->client->loginUser($this->user);
    $this->client->request('POST', "/api/quiz-sessions/{$session->getId()}/answers", ...);

    self::assertResponseStatusCodeSame(403);
}