DEV Community

Cover image for Symfony 8.2 Console Sub-Commands and the Deploy Rollback Trap
Qasim Parray
Qasim Parray

Posted on Originally published at abrarqasim.com

Symfony 8.2 Console Sub-Commands and the Deploy Rollback Trap

Confession: I have typed php bin/console cache clear with a space instead of a colon more times than I'd like to admit, usually right after a week of docker compose up and git stash pop. Symfony would print the namespace listing, I'd sigh, retype it with the colon, and move on. Yesterday I read the Symfony 8.2 console sub-commands announcement and realised that particular sigh is going away. As of 8.2, cache clear and cache:clear run the same command.

That's the cute headline. The part I actually care about is underneath it: commands whose names share a prefix now form a tree, each level of the tree can parse its own options, and a child command can read what its parent was given. That's a real change to how you can structure a CLI, and it comes with one behaviour change that will bite somebody in production. I want to walk through both, with code, and then say where I think it's worth using and where I'd leave the colons alone.

What changed, in one example

Symfony's console has always grouped commands with colons. messenger:consume, doctrine:migrations:migrate, that sort of thing. The colon is purely cosmetic to the framework. Nothing about doctrine:migrations:migrate knows it lives under doctrine. It's a flat registry with a naming convention.

Docker and Git went the other way. docker compose up is three words, and each word is a level that can take its own flags. git remote add origin works the same way. Symfony 8.2 lets you build that shape.

Here's the shape from the announcement, slightly trimmed. A tenant command that only holds an option, and a tenant:users:import command that does the work:

// src/Command/TenantCommand.php
#[AsCommand(name: 'tenant', description: 'Manages tenants')]
class TenantCommand extends Command
{
    protected function configure(): void
    {
        $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'The tenant name');
    }
    // no execute(): running "tenant" alone lists its sub-commands
}

// src/Command/ImportUsersCommand.php
#[AsCommand(name: 'tenant:users:import', description: 'Imports users from a CSV file')]
class ImportUsersCommand
{
    public function __invoke(
        #[Argument] string $file,
        #[Option] bool $dryRun = false,
    ): int {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Both of these run the same import:

php bin/console tenant users import customers.csv --dry-run
php bin/console tenant:users:import customers.csv --dry-run
Enter fullscreen mode Exit fullscreen mode

The difference shows up when you add the parent's option. In the spaced form, tenant parses --name and only the last command in the chain actually executes:

php bin/console tenant --name=acme users import customers.csv --dry-run
Enter fullscreen mode Exit fullscreen mode

Notice users has no class of its own. It's an implicit node that exists because tenant:users:import is registered. You don't have to build every rung of the ladder. That's also why cache clear just works: cache is an existing namespace, so it becomes a node for free.

Reading the parent's input

This is the bit I'd been faking for years. The old way to share an option between a parent and its children was to declare it on every child, or to stash it in a service, or to give up and pass an environment variable. None of those felt good.

In 8.2, a sub-command doesn't inherit its parent's options. Instead you inject a CommandChain and ask it for the input of whichever command resolved earlier in the chain:

use Symfony\Component\Console\CommandChain;

public function __invoke(
    CommandChain $chain,
    #[Argument] string $file,
    #[Option] bool $dryRun = false,
): int {
    $tenantName = $chain->getInput('tenant')?->getOption('name');
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The nullsafe operator there is not decoration. If someone runs tenant:users:import directly with the colon, tenant was never part of the chain, so getInput('tenant') returns null. Your child command has to cope with both spellings. I got this wrong in my first ten minutes of playing with it, because I assumed the chain would be populated either way. It isn't. The chain reflects what the user typed, not the tree you designed.

getInput() also accepts a class name, so getInput(TenantCommand::class) works if you'd rather not hardcode the string. And outside a command, say in an event listener, Application::getCommandChain() hands you the same object while a command is running.

Application-level options like -v and --env are accepted at any level, so tenant -v users import and tenant users import -v are the same thing. Shell completion and --help understand the tree as well. tenant users im<TAB> completes to import, and tenant users import --help prints the help for import, not for tenant.

One class, whole tree

Symfony 8.1 let you put several commands in one class as methods, with a class-level #[AsCommand] acting as a shared prefix. 8.2 finishes that thought. The parent's description and options now live in the class attribute, and each method is a child:

#[AsCommand('tenant', description: 'Manages tenants', options: [
    new InputOption('name', null, InputOption::VALUE_REQUIRED, 'The tenant name'),
])]
class TenantCommands
{
    public function __construct(private TenantRepository $tenants) {}

    #[AsCommand('users:import', description: 'Imports users from a CSV file')]
    public function importUsers(
        CommandChain $chain,
        #[Argument] string $file,
        #[Option] bool $dryRun = false,
    ): int {
        $tenantName = $chain->getInput('tenant')?->getOption('name');
        // ...
        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

That class registers two commands, tenant and tenant:users:import, and the group has no code at all. Running tenant alone lists its children and exits with code 1, same as a bare namespace does today.

I like this more than I expected to. For a small internal tool with six related commands, one file with six methods and one shared option block reads better than six classes and a trait. Nicolas Grekas did both pieces of this work, in symfony/symfony#65825 for the tree resolution and #65853 for the grouped attributes, and the second one only makes sense once the first exists.

The change that will bite someone

Here is the part I would put in bold if I used bold. Sub-commands always win over arguments.

Suppose you have a deploy command with a target argument, and you also register deploy:rollback. Before 8.2, this ran deploy with target=rollback:

php bin/console deploy rollback
Enter fullscreen mode Exit fullscreen mode

In 8.2 it runs deploy:rollback. Same characters typed, different command executed. If you want the old meaning you now need the double dash:

php bin/console deploy -- rollback
Enter fullscreen mode Exit fullscreen mode

Think about how narrow the trigger is. You need a command with a positional argument, and a sibling whose last segment happens to equal a value someone passes. deploy staging is fine. deploy rollback silently changes meaning. user delete admin is fine unless somebody later adds user:delete:admin as a convenience command, at which point the plain invocation stops deleting the user called admin and starts running the new thing.

This isn't a bug. It's the only sane precedence rule for a tree, and the announcement documents it plainly. But it's a behaviour change in a minor release that no static analyser is going to flag for you, because the code compiles either way. I wrote about a related class of problem in the Symfony LSP route typo PHPStan can't see; string-shaped contracts fail at runtime, and command names are string-shaped contracts.

My rule after reading this: before upgrading a project to console 8.2, grep the deploy scripts and cron entries for any invocation where an argument value matches the last segment of another command name. It takes five minutes. Finding out in a cron log at 3am takes longer.

What this means if you live in Laravel

Artisan is built on symfony/console. Every php artisan make:model you've ever typed went through the same component this post is about. So the obvious question is whether you get spaced sub-commands in Laravel.

The honest answer is not yet, and not automatically. Laravel's Command base class wraps Symfony's and adds its own signature parser ({argument} and {--option} syntax), its own input handling, and its own listing. The tree resolution in 8.2 happens inside Symfony's Application when it matches a command name, and Laravel extends that Application. Whether php artisan cache clear starts working depends on which symfony/console version Laravel pins and whether Laravel's Application subclass overrides the bits that resolve names. I haven't tested it against a Laravel install yet, so I'm not going to claim either way. Watch the symfony/console constraint in laravel/framework and the Artisan docs rather than my guess.

What you can do today in Laravel is the boring version of the same idea. Define a parent-ish command whose only job is to list its children, keep the colon names, and share configuration through a service or a trait. It's less elegant. It also works on every version.

Where I'd use it, and where I wouldn't

I'd use spaced sub-commands in a tool that other people will type by hand. Deployment CLIs, tenant admin tools, anything that lives next to docker and git in someone's shell history. The muscle memory argument is real. Nobody remembers whether it's tenant:users:import or tenant:user:import, but tenant users im<TAB> sorts that out.

I wouldn't bother for commands that only ever run from a scheduler or a deploy script. Those get typed once, into a config file, and never again. The colon form still works, the tree is opt-in by prefix, and there's no benefit to touching a cron line that has been correct since 2022.

I'd also hold off on shared parent options for anything security-adjacent. tenant --name=acme users delete reads nicely, but the tenant name is now resolved in one command and consumed in another, through a chain that's null if the user picked the other spelling. That's two places for a bug to hide instead of one. For a destructive command I'd keep the tenant as an explicit argument on the command that does the deleting. Boring and greppable.

Try it this week

If you maintain a Symfony CLI with more than a handful of commands, do this on a branch. Bump symfony/console to 8.2 in a throwaway checkout, then run your existing commands with the spaced spelling and see which ones you get for free. Then run this against your scripts:

grep -rn "bin/console" deploy/ .github/ crontab* 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

Read each line and ask whether any argument value matches the last segment of another registered command. If none do, you can upgrade without thinking about it. If one does, add the -- now, before the upgrade, because it's harmless on 8.1 and required on 8.2.

I build this kind of internal tooling for clients fairly often, and a CLI that people can guess their way through is worth more than one that's technically complete. If you want a second pair of eyes on a console app or an upgrade plan, here's what I work on.


Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.

Top comments (0)