Aller au contenu principal

Symfony 8.1 : vos Commands passent en mode Controller.

Découvrez comment Symfony 8.1 modernise les Commandes Console avec les value resolvers Doctrine côté console, comme #[MapEntity], pour plus de confort et de performances.

6 min de lecture
Sommaire · 8

Symfony 8.1 amène les value resolvers Doctrine côté Console. C'est précisément ce qui restait : #[MapEntity], BackedEnumValueResolver, UidValueResolver, DateTimeValueResolver deviennent utilisables directement dans la signature de __invoke() d'une commande, comme ils le sont côté Controller depuis la 6.2.

Le reste de la modernisation des Command est arrivé avant : __invoke() direct (sans extends Command) et #[Argument] / #[Option] sur les paramètres → 8.0. #[Autowire] dans une signature → encore plus ancien (6.x). 8.1 ferme la dernière marche.

L'upgrade 8.1 n'a touché qu'une seule commande pour la feature elle-même. Mais à partir du moment où on ouvre le fichier, on remarque que l'une des commandes voisine laisse traîner encore le squelette extends Command + configure() + execute() depuis l'époque pré-__invoke() ; comme quoi se relire n'est jamais du luxe.

Le cas de la commande qui sert à paramétrer le F2A sur un utilisateur

app:user:2fa-setup prend un nom d'utilisateur en argument et active le TOTP pour ce user. Jusqu'à 8.0, on avait #[Argument] côté Console et on pouvait écrire __invoke() sans extends Command, mais le findOneBy() pour résoudre le user à partir du username restait à la main. C'est précisément ce trou que #[MapEntity] côté Console comble.

Avant

PHP
public function __invoke(
    SymfonyStyle $symfonyStyle,
    #[Argument(description: "Nom d'utilisateur (optionnel)")]
    ?string $username = null,
): int {
    $username = $this->resolveUsername($username, $symfonyStyle);
    $user = $this->findUser($username);

    if (!$user instanceof User) {
        $symfonyStyle->error(\sprintf('Utilisateur "%s" introuvable.', $username));

        return Command::FAILURE;
    }
    // …
}

private function resolveUsername(?string $value, SymfonyStyle $symfonyStyle): string
{
    if ($value !== null && $value !== '') {
        return $value;
    }

    $username = $symfonyStyle->ask("Nom d'utilisateur");

    return \is_string($username) ? $username : '';
}

private function findUser(string $username): ?User
{
    return $this->entityManager
        ->getRepository(User::class)
        ->findOneBy(['username' => $username])
    ;
}

Après

PHP
public function __invoke(
    SymfonyStyle $io,
    #[Argument(description: "Nom d'utilisateur", name: 'username')]
    #[MapEntity(mapping: ['username' => 'username'])]
    User $user,
): int {
    if ($user->isTotpAuthenticationEnabled()) {
        // …
    }
    // …
}

Et le constructeur perd l'EntityManagerInterface (uniquement utilisé pour getRepository()).

Décryptage par bloc

MapEntity côté Console marche exactement comme côté controller : on déclare la correspondance entre le nom d'argument console et la propriété de l'entité (ici usernameUser::$username, qui porte une UniqueConstraint Doctrine — pré-requis pour que le mapping soit déterministe).

Si l'argument est passé et qu'aucun user ne matche, le resolver lève une exception, la commande sort en erreur. Plus de message custom à écrire — c'est aussi le même comportement que côté HTTP (NotFoundHttpException).

Les deux méthodes privées resolveUsername() et findUser() disparaissent. 31 lignes en moins, et la signature de __invoke() documente intégralement le contrat de la commande.

Le pré-requis non documenté

On déclare #[MapEntity] dans une Command, on lance, ça plante. Le résolveur Symfony\Bridge\Doctrine\ArgumentResolver\Console\EntityValueResolver existe bien dans symfony/doctrine-bridge, mais doctrine-bundle ne le tag pas encore avec console.argument_value_resolver — seul son pendant HTTP est câblé (cf. vendor/doctrine/doctrine-bundle/config/orm.php:165). On déclare le service à la main, côté projet :

PHP
// config/services.php
use Symfony\Bridge\Doctrine\ArgumentResolver\Console\EntityValueResolver as ConsoleEntityValueResolver;

$services->set('app.console.entity_value_resolver', ConsoleEntityValueResolver::class)
    ->args([
        service('doctrine'),
        service('doctrine.orm.entity_value_resolver.expression_language')->ignoreOnInvalid(),
    ])
    ->tag('console.argument_value_resolver', ['priority' => 110, 'name' => ConsoleEntityValueResolver::class])
;

Priority 110 pour passer devant les défauts Console (BuiltinType, BackedEnum, etc. à 100) — même choix que le résolveur HTTP. À retirer dès que doctrine-bundle expose le câblage upstream.

Détail à mentionner pour qui découvre #[MapEntity] côté Console : le nom du paramètre PHP n'est pas toujours celui de l'argument console. Sur User $user avec un argument username, on rend explicite l'alias dans l'attribut, sinon le mapping ne trouve rien :

PHP
#[Argument(description: "Nom d'utilisateur", name: 'username')]
#[MapEntity(mapping: ['username' => 'username'])]
User $user,

Le compromis assumé

Cette refacto perd le prompt interactif ($io->ask("Nom d'utilisateur") quand l'argument est omis). C'est un choix.

Cette commande est appelée dans deux contextes : bootstrap manuel d'un user en local, et provisioning Ansible post-deploy. Dans les deux cas, l'opérateur connaît le username au moment où il tape la commande. Le prompt servait surtout à éviter une erreur explicite si on oubliait l'argument — c'est exactement ce que le resolver fait maintenant, avec un meilleur message.

Si vous tenez au prompt, alternative : ?User $user = null + prompt interactif si null. Mais alors MapEntity ne se déclenche pas, et on est en train de réécrire la plomberie qu'on voulait supprimer. Self-defeating.

Le ménage qui suit : une Command que 8.1 ne change pas, mais qu'on aligne quand même

Le scénario typique : on upgrade en 8.1, on patche User2FaSetupCommand pour brancher #[MapEntity], on ouvre la prochaine Command pour vérifier qu'elle compile encore, et là on voit le décalage. La voisine est toujours en extends Command + configure() + execute(), comme avant 8.0. La feature 8.1 ne lui apporte rien — mais c'est l'occasion de rattraper deux cycles de retard d'un coup.

Deux exemples courts, dans cet état d'esprit « tant qu'on y est ».

Avant : extends Command, configure() qui appelle addOption(), execute() qui fait $input->getOption('dry-run') avec un cast (bool). Une trentaine de lignes de plomberie pour une option booléenne.

Après :

PHP
#[AsCommand(
    name: 'reading-list:warm',
    description: 'Force le rebuild du snapshot « À lire » de la home (chantier #14).',
)]
final readonly class WarmReadingListCommand
{
    public function __construct(
        private ReadingListBuilder $builder,
        private ReadingListRepository $repository,
    ) {}

    public function __invoke(
        SymfonyStyle $io,
        #[Option(description: 'Construit le snapshot et l\'affiche, sans écrire dans Redis.')]
        bool $dryRun = false,
    ): int {
        // …
    }
}

configure() disparaît, la description vit dans l'attribut à côté du type, le cast (bool) disparaît, parent::__construct() aussi. C'est disponible depuis 8.0 — si votre repo ne l'a pas encore adopté, c'est l'occasion. 23 lignes en moins pour la même surface d'API.

Le mot de la fin

Le pivot 8.1 est plus large que cette feature. Sur quatre nouveautés majeures (HTTP-Less Apps, DeepCloner, Console Argument Resolvers Doctrine, TUI component), trois changent la façon dont on écrit la couche non-HTTP d'une app Symfony. C'est intentionnel — Symfony se positionne explicitement comme un framework au-delà du web.

Pendant des années, on a écrit des controllers fins et des commandes obèses parce que le framework rendait l'inverse difficile. La 7.3 puis la 8.0 ont fait l'essentiel du travail (__invoke() direct, attributs #[Argument] / #[Option]). La 8.1 ferme la dernière marche en alignant les value resolvers. Vu chacune isolément, aucune n'est spectaculaire. Vu ensemble, c'est une décision claire : une Command n'est plus une seconde classe de citoyens.

Et puisque chaque cycle apporte sa pièce, il faut ouvrir le tiroir pour la voir. Sans ça, la dette s'accumule en silence : du code parfaitement fonctionnel mais qui parle un dialecte d'il y a deux versions. La leçon perso de cet upgrade : à chaque montée de version mineure de Symfony, prévoir un demi-quart d'heure pour grep extends Command et $input->getArgument( dans le repo. Le retour sur investissement est gratuit.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.