Invokable Console Commands in Symfony 8
The traditional way to write a Symfony console command involves extending Command, overriding configure() to register arguments and options, and then reading them back from $input in execute(). It works, but it's repetitive.
Since Symfony 7.3, you can skip all that. Write an invokable class, add #[Argument] and #[Option] attributes to the __invoke() parameters, and Symfony handles the rest.
Basic example
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-user',
description: 'Create a new user account',
)]
class CreateUserCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument] string $username,
#[Option] bool $activate = false,
): int {
// ... create user logic
$io->success(sprintf('User "%s" created.', $username));
return Command::SUCCESS;
}
}
No base class to extend. No configure(). No $input->getArgument('username'). The #[Argument] attribute handles registration, and SymfonyStyle is injected directly into __invoke().
php bin/console app:create-user jane
php bin/console app:create-user jane --activate
Arguments
Arguments are positional. The PHP type and default value tell Symfony whether the argument is required or optional.
// Required
#[Argument] string $username,
// Optional with default
#[Argument] int $retries = 3,
// Nullable optional
#[Argument] ?string $email = null,
// Variadic (collects all remaining values)
#[Argument] array $tags = [],
If the CLI name should differ from the PHP variable name, pass it explicitly:
#[Argument(name: 'user-name')] string $userName,
Options
Options are named (--flag) and always need a default value.
// Boolean flag
#[Option] bool $sendEmail = false,
// With shortcut
#[Option(shortcut: 'f')] bool $force = false,
// Value option
#[Option] string $role = 'ROLE_USER',
// Nullable (--timeout with no value gives null)
#[Option] ?int $timeout = null,
// Array (--group=admin --group=editor)
#[Option] array $groups = [],
Enum support
From Symfony 7.4 onward (including 8.x), backed enums work out of the box. User input is cast automatically, and invalid values produce a clear error.
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
}
#[AsCommand(name: 'app:create-user', description: 'Create a new user account')]
class CreateUserCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument] string $username,
#[Option] UserRole $role = UserRole::Viewer,
): int {
$io->writeln(sprintf('Creating "%s" with role "%s"', $username, $role->value));
return Command::SUCCESS;
}
}
php bin/console app:create-user jane --role=admin
Invalid enum values produce a clear error with the list of accepted values:
$ php bin/console app:create-user jane --role=superadmin
The value "superadmin" is not valid for the "role" option.
Supported values are "admin", "editor", "viewer".
Grouping inputs with #[MapInput]
When a command has many parameters, you can collect them into a dedicated class and inject it as a single #[MapInput] argument. This keeps __invoke() readable and makes the input class easy to test in isolation.
DTO properties must be public and non-static. DTOs can also be nested, and Symfony merges them automatically.
class CreateUserInput
{
#[Argument]
public string $username;
#[Option]
public UserRole $role = UserRole::Viewer;
#[Option]
public bool $activate = false;
}
#[AsCommand(name: 'app:create-user', description: 'Create a new user account')]
class CreateUserCommand
{
public function __invoke(SymfonyStyle $io, #[MapInput] CreateUserInput $input): int
{
$io->success(sprintf('User "%s" (%s) created.', $input->username, $input->role->value));
return Command::SUCCESS;
}
}
Interactive commands
For commands that need to prompt for missing input, use #[Interact] on a method in the same class. It runs before __invoke(), just like the old interact() override.
#[AsCommand(name: 'app:create-user', description: 'Create a new user account')]
class CreateUserCommand
{
#[Interact]
public function prompt(InputInterface $input, SymfonyStyle $io): void
{
if (!$input->getArgument('username')) {
$input->setArgument('username', $io->ask('Username'));
}
}
public function __invoke(
SymfonyStyle $io,
#[Argument] string $username,
#[Option] UserRole $role = UserRole::Viewer,
): int {
$io->success(sprintf('User "%s" created.', $username));
return Command::SUCCESS;
}
}
For simpler cases where you only need to ask for a missing value, #[Ask] is more concise. You can put it directly on the parameter or on a DTO property:
public function __invoke(
SymfonyStyle $io,
#[Argument, Ask('Username')] string $username,
): int {
// ...
}
class CreateUserInput
{
#[Argument]
#[Ask('Username')]
public string $username;
}
Service injection
With Symfony's autoconfiguration, any class tagged with #[AsCommand] is registered automatically. Services go in the constructor as usual.
#[AsCommand(name: 'app:create-user', description: 'Create a new user account')]
class CreateUserCommand
{
public function __construct(
private readonly UserRepository $users,
private readonly UserCreator $creator,
) {}
public function __invoke(
SymfonyStyle $io,
#[Argument] string $username,
#[Option] UserRole $role = UserRole::Viewer,
): int {
if ($this->users->findByUsername($username) !== null) {
$io->error(sprintf('User "%s" already exists.', $username));
return Command::FAILURE;
}
($this->creator)(username: $username, role: $role);
$io->success(sprintf('User "%s" created.', $username));
return Command::SUCCESS;
}
}
The old configure() + execute() pattern still works and is not deprecated. No need to migrate existing commands.