backFebruary 22, 2026

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.