Logo de Symfony avec le numéro 8.1
Backend
12 minutes

À la découverte de Symfony 8.1

Publié le

Disponible depuis le 29 mai, Symfony 8.1 met l'accent sur les applications CLI tout en apportant de nombreuses améliorations à l'ensemble du framework

Quelques mois après le lancement de Symfony 8, le framework accueille la première version mineure de ce cycle. Cette mise à jour met les applications en ligne de commande à l'honneur avec une multitude de nouveautés. Les autres composants ne sont pas en reste et profitent eux aussi de nombreuses améliorations, tant en matière d'expérience développeur que de fonctionnalités. Découvrons ensemble les nouveautés incontournables de cette version.

Le terminal au cœur de cette version

Au-delà du HTTP

Depuis ses débuts, Symfony a été construit autour du cycle de vie des requêtes HTTP. Jusqu'ici, même l'exécution d'une simple commande ou d'un worker reposait sur le composant HttpKernel pour initialiser le framework et son conteneur de services.

Avec cette version, Symfony extrait le noyau et la gestion des bundles au sein du composant DependencyInjection, ouvrant la voie à des applications entièrement indépendantes de la stack HTTP.

Ainsi, une application en ligne de commande aura uniquement besoin du ConsoleBundle pour fonctionner :

return [
    Symfony\Component\Console\ConsoleBundle::class => ['all' => true],
];

Conformément à la stratégie de versionnage du framework, cette évolution est entièrement rétrocompatible et permet aux applications existantes de continuer à fonctionner sans modification.

Si tu veux creuser les détails techniques de ce changement architectural, je t'invite à consulter l'article dédié sur le blog de Symfony.

Une classe, plusieurs commandes

Jusqu'à présent, chaque commande devait être définie dans une classe dédiée, décorée avec l'attribut #[AsCommand]. Cette approche multipliait parfois inutilement les fichiers et pouvait entraîner une certaine répétitivité, notamment au niveau de l'injection des dépendances.

Il est désormais possible de regrouper plusieurs commandes au sein d'une même classe en appliquant l'attribut #[AsCommand] directement sur les méthodes, plutôt que sur la classe.

final class OrderCommand
{
    public function __construct(
        private OrderFileManager $orderFileManager,
        private OrderRepository $orderRepository,
    ) {
    }

    #[AsCommand('app:order:import', description: 'Import orders')]
    public function import(OutputInterface $output): int
    {
	// ...

        return Command::SUCCESS;
    }

    #[AsCommand('app:order:export', description: 'Export orders')]
    public function export(OutputInterface $output): int
    {
        // ...

        return Command::SUCCESS;
    }
}

Cette approche est idéale pour regrouper les commandes liées à une même fonctionnalité, améliorant ainsi l'organisation du code.

Une résolution améliorée des arguments et des options

Depuis Symfony 7.3, les attributs #[Argument] et #[Option] simplifient largement la gestion des paramètres de nos commandes.

#[AsCommand('app:order:cancel', description: 'Cancel order')]
public function cancel(
    #[Argument] int $orderId,
    #[Option] ?string $canceledAt = null
): int {
    // ...

    return Command::SUCCESS;
}

Cette version améliore encore l'expérience développeur en automatisant la résolution des paramètres. Tout comme dans les contrôleurs, les arguments et options peuvent désormais être directement transformés en objets, sans logique explicite dans la commande.

#[AsCommand('app:order:cancel', description: 'Cancel order')]
public function cancel(
    #[Argument, MapEntity] Order $order,
    #[Option, MapDateTime(format: 'Y-m-d')] DateTimeInterface $canceledAt = new DateTime()
): int {
    // ...

    return Command::SUCCESS;
}

Tester les commandes n'a jamais été aussi simple

Auparavant, lancer une commande dans un test nécessitait une certaine quantité de code : cela devenait rapidement laborieux et répétitif. Avec Symfony 8.1, un raccourci simplifie désormais considérablement ce type de test.

class OrderCommandTest extends KernelTestCase
{
    public function testCancel(): void
    {
        $commandTester = static::runCommand('app:order:cancel', [
            'order' => 1,
        ]);

        $commandTester->assertCommandIsSuccessful();
    }
}

En complément, deux nouvelles assertions sont ajoutées pour contrôler le code de sortie de la commande :

$commandTester->assertCommandFailed();
$commandTester->assertCommandIsInvalid();

Des entrées utilisateurs améliorées

Plusieurs fonctionnalités viennent simplifier les interactions entre l'utilisateur et la commande.

Depuis la version 7.4, l'attribut #[Ask] permet d'interroger l'utilisateur pour lui demander un argument manquant. Cette itération ajoute la possibilité de valider les données saisies.

#[AsCommand('app:order:import', description: 'Import orders')]
public function import(
    #[Argument, Ask('Enter import URL:', constraints: [
        new Assert\NotBlank(), 
    	new Assert\Url(),
    ])] string $url,
): int {
    // ...

    return Command::SUCCESS;
}

Il est également possible de laisser l'utilisateur choisir parmi un ensemble de valeurs prédéfinies.

#[AsCommand('app:order:export', description: 'Export orders')]
public function export(
    #[Argument, AskChoice('Select a status', ['pending', 'paid', 'shipped'])] string $status
): int {
    // ...

    return Command::SUCCESS;
}

Cette fonctionnalité prend également en charge les énumérations. Dans ce cas, les choix sont automatiquement définis à partir des valeurs de cette dernière.

#[AsCommand('app:order:export', description: 'Export orders')]
public function export(
    #[Argument, AskChoice('Select a status')] Status $status
): int {
    // ...

    return Command::SUCCESS;
}

Pour terminer, l'utilisateur peut également fournir une image en la collant directement dans le terminal.

#[AsCommand('app:order:attach', description: 'Attach document to order')]
public function attach(
    #[Argument, Ask('Provide an image:')] InputFile $image,
): int {
    // ...

    return Command::SUCCESS;
}

Dans les terminaux non compatibles, l'utilisateur pourra simplement saisir le chemin du fichier.

La communauté Symfony travaille actuellement sur un composant TUI permettant de créer des interfaces riches dans le terminal. Bien que disponible dans la version 8.1, ce composant est encore en développement, il ne dispose pas encore de documentation et reste susceptible d'évoluer. Nous en reparlerons donc plus tard.

Du clonage récursif sans forcer

En PHP, cloner un objet est hyper simple, enfin, à première vue :

$clone = clone $object;

Le mot-clé clone ne copie en réalité que le premier niveau de l'objet. Les objets imbriqués partagent toujours la même référence. Il existe bien sûr plusieurs solutions pour contourner ce problème, des méthodes magiques au composant maison, en passant par la sérialisation / dé-sérialisation sauvage. Ces méthodes présentent chacune leurs inconvénients.

Aujourd'hui, Symfony apporte une réponse à cette problématique récurrente avec l'ajout du DeepCloner : un simple appel suffit pour cloner l'objet en profondeur.

$clone = DeepCloner::deepClone($object);

Cette fonctionnalité permet également d'optimiser le clonage multiple en analysant une seule fois l'objet d'origine.

$cloner = new DeepCloner($object);

$clone1 = $cloner->clone();
$clone2 = $cloner->clone();

En complément, l'équipe Symfony publie une extension PHP ext-deepclone apportant des fonctions natives pour simplifier certaines opérations de clonage.

Des contrôleurs toujours plus autonomes

Un attribut pour le RateLimiter

Le composant RateLimiter permet de limiter le nombre d'actions dans une fenêtre de temps. Pour cela, il faut définir une ou plusieurs politiques de limitation dans la configuration du composant, puis injecter le service dans le contrôleur afin de vérifier manuellement que l'utilisateur n'a pas atteint la limite.

Symfony 8.1 introduit un attribut #[RateLimit] permettant de décorer directement les contrôleurs et ainsi d'éviter cette vérification manuelle.

class ChatbotController extends AbstractController
{
    #[RateLimit('chatbot')]
    public function chat(): JsonResponse { /* ... */ }

    #[RateLimit('chatbot', tokens: 5)]
    public function generateImage(): JsonResponse { /* ... */ }
    
    #[RateLimit('email', key: new Expression('request.request.get("email")'))]
    public function shareSession(): JsonResponse { /* ... */ }
}

Cet attribut propose également plusieurs options notamment pour personnaliser le nombre de crédits utilisés ou la clé d'unicité du limiteur.

Une sérialisation simplifiée

Les contrôleurs sont souvent amenés à renvoyer du contenu sérialisé. Il existe déjà plusieurs méthodes pour y parvenir, que ce soit en injectant le service de sérialisation et en l'appelant manuellement ou en utilisant le raccourci $this->json() de l'AbstractController.

Pour les adeptes des attributs, Symfony propose désormais une alternative déclarative avec #[Serialize].

final class UserController
{
    #[Serialize]
    public function get(User $user): User
    {
        return $user;
    }
    
    #[Serialize(
        code: 201,
    )]
    public function create(Request $request): array
    {
        // ...
        
        return [
            'status' => 'created',
            'user' => $user,
        ];
    }
}

Cette nouveauté permet de s'affranchir de l'AbstractController et de la sérialisation manuelle.

Mapper les fichiers entrant automatiquement

L'introduction de l'attribut #[MapRequestPayload], permettant d'hydrater automatiquement un objet à partir des données de la requête, est selon moi l'un des ajouts récents les plus pratiques.

Cet outil gagne encore en puissance en permettant d'alimenter directement l'objet avec les fichiers téléversés lors de la requête.

class User
{
    public ?string $username = null;
    public ?string $email = null;
    public ?UploadedFile $avatar = null;
}

final class UserController
{
    public function create(
        #[MapRequestPayload] User $user,
    ): Response {
        $this->userAvatarService->save($user->avatar);
    }
}

Récupérer des en-têtes à la vitesse de l'éclair

Dans la lignée de #[MapRequestPayload], un attribut #[MapRequestHeader] est introduit pour récupérer une en-tête de la requête sans passer par l'objet Request.

class DocumentController extends AbstractController
{
    public function sign(
        #[MapRequestHeader] string $acceptLanguage,
        #[MapRequestHeader(name: 'x-stamp')] string $stamp,
        #[MapRequestHeader(name: 'x-signers')] array $signers,
    ): Response {
        // ...
    }
}
  • Si aucun nom n'est précisé, le nom de la variable sera utilisé après une transformation en kebab case
  • Le typage en tableau permet de récupérer toutes les valeurs

C'est peut être un détail pour vous, mais pour moi ça veut dire beaucoup

Une contrainte pour valider le XML

À mon grand regret, le XML tient toujours une place importante dans les échanges entre applications. Qu'à cela ne tienne, le validateur nous apporte une nouvelle contrainte pour valider le format d'un contenu XML.

#[Assert\Xml]
public string $payload;

En plus de vérifier la syntaxe globale, cette contrainte peut également contrôler que la structure respecte un schéma XSD défini.

#[Assert\Xml(schemaPath: 'config/schemas/request.xsd')]
public string $payload;

Le traitement par lot débarque dans Messenger

Lors d'un usage intensif du composant Messenger, la récupération des messages un par un représente un coût en termes de performances à cause de la latence lors de la communication avec la couche de transport.

L'option fetch-size est introduite pour définir le nombre de messages à récupérer à chaque requête :

php bin/console messenger:consume async --fetch-size=10

Le traitement par lot représente une belle opportunité d'optimisation à bas coût.

Conclusion

Ce printemps, Symfony nous livre une version 8.1 solide, riche en nouveautés pour la productivité et l'expérience développeur. Cette itération marque aussi un tournant architectural en découplant le noyau du flux HTTP.

Dans cet article, j'ai sélectionné mes nouveautés favorites. Cette version a bien plus à offrir, tu peux retrouver l'ensemble des ajouts sur le blog officiel.

À bientôt sur ce blog ou en live sur Twitch 👋.