PHP 8 et Symfony : la révolution discrète qui change tout

Temps de lecture : 7 min

Avec l'avènement de PHP 8, Symfony a abandonné les annotations au profit des attributs natifs, unifiant la configuration directement au cœur du code. Cette évolution philosophique n'est pas un gadget : elle redéfinit en profondeur la manière de déclarer les routes, la sécurité, l'injection de dépendances et la validation, rendant le code plus robuste et lisible.

Si vous avez commencé avec Symfony avant la version 5.2, vous avez connu l'ère des annotations. Ces blocs de commentaires /** @Route(...) */ ou /** @Assert\NotBlank */ étaient puissants, mais restaient... des commentaires. Ils nécessitaient une bibliothèque externe pour être lus et n'étaient pas nativement compris par PHP.

Avec PHP 8, les attributs ont tout changé.

Ce ne sont plus des commentaires, mais de véritables classes PHP (#[Route(...)]). Symfony a massivement adopté cette fonctionnalité pour rendre la configuration plus déclarative, plus robuste et directement intégrée au langage.

Loin de se limiter aux routes et à la validation, les attributs sont désormais omniprésents. Faisons un tour d'horizon complet de la manière dont ils ont redéfini la configuration de Symfony, composant par composant.

Le cœur : HttpKernel, Routage et Controllers

C'est la partie la plus visible. Fini la configuration des Controllers dans services.yaml ou les routes dans routes.yaml. Tout se passe dans la classe.

Routage

L'attribut #[Route] est le plus connu. Il définit les URL, les méthodes HTTP et les noms de vos routes.

use Symfony\Component\Routing\Attribute\Route;

class BlogController
{
    #[Route('/blog/{slug}', name: 'blog_show', methods: ['GET'])]
    public function show(string $slug)
    { 
        /* ... */ 
    }
}

Mapping de la Requête (HttpKernel)

C'est là que la magie opère. Plus besoin de manipuler l'objet Request manuellement pour en extraire des données.

  • #[MapRequestPayload] : Désérialise automatiquement le corps d'une requête (JSON, form-data) dans un DTO (Data Transfer Object).
  • #[MapQueryString] : Fait la même chose pour les paramètres GET (?page=1&sort=asc).
  • #[MapUploadedFile] : Injecte directement un fichier uploadé dans un argument.
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
// N'oubliez pas d'importer 'Route' si ce n'est pas déjà fait dans un autre fichier
use Symfony\Component\Routing\Attribute\Route; 

class ApiController
{
    // POST /api/users avec un body JSON
    // Le JSON est automatiquement mappé sur $userDto
    #[Route('/api/users', methods: ['POST'])]
    public function create(#[MapRequestPayload] CreateUserDto $userDto)
    {
        // $userDto est déjà hydraté et validé (si Validator est activé)
    }

    // GET /api/posts?page=2&limit=10
    // Les query params sont mappés sur $filters
    #[Route('/api/posts', methods: ['GET'])]
    public function list(#[MapQueryString] PostFilters $filters)
    {
        // $filters->page vaut 2, $filters->limit vaut 10
    }
}

Le moteur : injection de dépendances

C'est peut-être le changement le plus profond. La configuration de vos services, de l'autowiring et des tags se fait désormais directement dans vos classes.

  • #[Autowire] : Permet de forcer l'injection d'un paramètre, d'une variable d'environnement ou d'un service spécifique.
use Symfony\Component\DependencyInjection\Attribute\Autowire;

// Le constructeur doit être à l'intérieur d'une classe
class MaClasse // (ou MonService, MonController, etc.)
{
    public function __construct(
        #[Autowire('%kernel.project_dir%/public/uploads')]
        private string $uploadDir
    ) {
        // Le corps du constructeur est vide, 
        // car la propriété est promue (PHP 8.0+)
    }
}
  • #[AsDecorator] : Configure le pattern Decorator sans une ligne de YAML.
  • #[AsAlias] : Crée un alias pour un service.
  • #[AsTaggedItem] : Ajoute un tag à votre service (équivalent du tag name en YAML).
  • #[TaggedIterator] / #[TaggedLocator] : Injecte tous les services ayant un tag spécifique, sous forme d'itérateur ou de locator.
  • #[Exclude] : Empêche un service d'être découvert automatiquement.

Le gardien : sécurité

Protéger vos endpoints devient trivial.

  • #[IsGranted] : L'attribut fondamental. Il vérifie un rôle ou une permission avant d'exécuter la méthode du contrôleur.
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

// (Ce code doit être à l'intérieur d'une classe)
class AdminController
{
    #[Route('/admin')]
    #[IsGranted('ROLE_ADMIN')]
    public function adminDashboard()
    { 
        /* ... */ 
    }
}
  • #[CurrentUser] : Une alternative à l'injection de Security $security ou au type-hint UserInterface. Il injecte directement l'objet utilisateur (ou null si non connecté).
  • #[IsCsrfTokenValid] : Valide un token CSRF pour une action spécifique (ex: suppression).

Le contrôleur qualité : validation

Toutes les contraintes de validation (@Assert\...) sont devenues des attributs. Cela rend la définition des DTO et des entités beaucoup plus propre.

use Symfony\Component\Validator\Constraints as Assert;

class RegistrationDto
{
    #[Assert\NotBlank(message: 'L\'email ne peut être vide.')]
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8, minMessage: 'Minimum 8 caractères.')]
    public string $password;
}

La console : commandes

Fini les propriétés statiques $defaultName ou la surcharge de la méthode configure(). L'attribut #[AsCommand] gère tout.

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;

// Le nom et la description sont définis ici
#[AsCommand(
    name: 'app:create-user',
    description: 'Crée un nouvel utilisateur.'
)]
class CreateUserCommand extends Command
{
    // ...
}

La mémoire : Doctrine (DoctrineBridge)

Bien que faisant partie de Doctrine, le bridge Symfony fournit des attributs essentiels pour lier le framework à l'ORM.

  • #[UniqueEntity] : S'assure qu'un champ (ou une combinaison de champs) est unique dans la base de données. C'est un attribut de validation appliqué au niveau de la classe Entité.
  • #[MapEntity] : Utilisé dans les Controllers pour remplacer le ParamConverter. Il fetch automatiquement une entité depuis la base de données en se basant sur un paramètre de route (comme {id} ou {slug}).
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[UniqueEntity(fields: ['email'], message: 'Cet email est déjà utilisé.')]
class User
{
    // ...
}

Le traducteur : Serializer

Quand vous construisez des API, vous devez contrôler finement ce qui est exposé. Les attributs du Serializer sont vos meilleurs amis.

  • #[Groups] : Définit les groupes de sérialisation pour exposer/cacher des propriétés.
  • #[Ignore] : Exclut systématiquement une propriété de la sérialisation/désérialisation.
  • #[SerializedName] : Permet de définir un nom différent pour la propriété dans le JSON/XML (ex: $firstName devient first_name).
  • #[MaxDepth] : Gère les problèmes de références circulaires.
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\Ignore;

class User
{
    #[Groups(['user:read'])]
    public int $id;

    #[Groups(['user:read', 'user:write'])]
    public string $email;

    #[Ignore] // Ne jamais exposer le mot de passe
    public string $password;
}

Et tous les autres composants...

La liste est exhaustive et prouve à quel point cette philosophie est centrale :

  • Messenger : #[AsMessageHandler] : déclare une classe comme handler pour un message spécifique.
  • EventDispatcher : #[AsEventListener] : abonne une méthode à un événement (remplace le tag kernel.event_listener).
  • Scheduler : #[AsCronTask] : définit une tâche planifiée avec une expression Cron. #[AsPeriodicTask] : définit une tâche récurrente (ex: "toutes les 30 minutes").
  • Twig : #[AsTwigFunction] / #[AsTwigFilter] : déclare une nouvelle fonction ou un nouveau filtre Twig depuis une classe PHP.
  • Workflow : #[AsTransitionListener], #[AsEnterListener], etc. : permet de s'accrocher à des événements spécifiques du cycle de vie d'un workflow.
  • Symfony UX : #[AsLiveComponent] : déclare un composant TwigComponent comme un composant Live (interactif). #[AsEntityAutocompleteField] : Configure un champ d'autocomplétion EasyAdmin.

Le mot de la fin

Les attributs ne sont pas un gadget. C'est une refonte philosophique : le code est devenu la configuration.

Au lieu de jongler entre un fichier YAML pour les services, un fichier XML pour Doctrine, des annotations pour les routes et du PHP pour la logique, tout est désormais unifié. Votre classe décrit son propre comportement : à quelle route elle répond, quelles données elle attend, quels rôles sont requis, et comment elle s'intègre au reste de l'application.

Cela améliore la lisibilité, la maintenabilité et la robustesse (votre IDE peut valider les attributs !). Si vous démarrez un projet Symfony aujourd'hui, les attributs sont la voie royale, complète et recommandée.