5 pratiques pour un back-end Symfony maintenable
Contrôleurs fins, DTO validés, traitements asynchrones, configuration propre et tests — cinq habitudes qui rendent un projet Symfony agréable à faire évoluer.
Un projet Symfony démarre toujours bien. C'est après quelques mois, quand les fonctionnalités s'accumulent, qu'on voit si le code tient la route. Voici cinq habitudes qui font la différence au quotidien.
1. Des contrôleurs fins, la logique dans des services
Un contrôleur devrait seulement recevoir la requête, appeler un service et renvoyer une réponse. La logique métier va dans des services, injectés automatiquement grâce à l'autowiring :
#[Route('/orders/{id}/cancel', methods: ['POST'])]
public function cancel(Order $order, OrderCanceller $canceller): Response
{
$canceller->cancel($order);
return $this->redirectToRoute('order_show', ['id' => $order->getId()]);
}
Résultat : la logique est testable seule et réutilisable ailleurs, depuis une commande console ou un message par exemple.
2. Des DTO validés pour les données entrantes
Plutôt que de lire le tableau de la requête champ par champ, on décrit les données attendues dans un objet, avec leurs règles de validation. Depuis Symfony 6.3, l'attribut MapRequestPayload remplit et valide cet objet automatiquement :
final class CreateContactRequest
{
public function __construct(
#[Assert\NotBlank] public readonly string $name,
#[Assert\Email] public readonly string $email,
#[Assert\Length(min: 10)] public readonly string $message,
) {}
}
#[Route('/api/contact', methods: ['POST'])]
public function contact(#[MapRequestPayload] CreateContactRequest $request): JsonResponse
{
// $request est déjà validé : en cas d'erreur, Symfony renvoie une 422
}
3. Sortir les traitements lents de la requête
Envoyer un email, générer un PDF ou appeler une API externe ne devrait pas faire attendre l'utilisateur. Avec Messenger, on envoie un message et un worker le traite en arrière-plan :
$bus->dispatch(new SendWelcomeEmail($user->getId()));
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Message\SendWelcomeEmail: async
On passe l'identifiant plutôt que l'entité entière : le worker recharge une donnée fraîche depuis la base.
4. Une configuration propre et sans secret dans le code
Les URLs, clés d'API et paramètres qui changent selon l'environnement passent par des variables d'environnement, injectées avec #[Autowire] :
public function __construct(
#[Autowire(env: 'PAYMENT_API_URL')] private readonly string $paymentApiUrl,
) {}
Pour les secrets en production, le coffre intégré (bin/console secrets:set) évite de les laisser traîner dans des fichiers.
5. Des tests et de l'analyse statique
Pas besoin de viser 100 % de couverture pour être serein. Des tests unitaires sur les services métier, quelques tests fonctionnels avec WebTestCase sur les parcours critiques, et PHPStan dans l'intégration continue attrapent déjà la majorité des régressions :
public function testCancelledOrderCannotBeCancelledTwice(): void
{
$order = OrderFactory::cancelled();
$this->expectException(OrderAlreadyCancelled::class);
(new OrderCanceller())->cancel($order);
}
En résumé
Aucune de ces pratiques n'est spectaculaire, et c'est justement leur force : appliquées dès le début, elles gardent un projet lisible, testable et facile à reprendre pour le développeur suivant — qui est souvent soi-même, six mois plus tard.