Skip to content

Conversation

@yceruto
Copy link
Member

@yceruto yceruto commented Aug 21, 2025

Q A
Branch? 7.4
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Introduce #[Input] to let invokable console commands receive a DTO that defines the command's arguments/options on its own public properties via #[Argument]/#[Option]. This avoids stuffing __invoke() with a long parameter list and keeps the command's input model in one place.

How it works

  • You put #[Input] on a __invoke() parameter, typed as your DTO class (it's not limited to a single parameter)
  • Inside that DTO, public non-static properties marked with #[Argument] and #[Option] become the command's input definition
  • At runtime, Symfony instantiates the DTO (without calling its constructor), resolves CLI values, and assigns them to the properties (recursively, if you nest DTOs)

Example

class UserInput
{
    #[Argument] 
    public string $email {
        set => strtolower($value); // normalize with a property hook
    }

    #[Argument] 
    public string $password;

    #[Option] 
    public bool $admin = false;
}

#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __invoke(#[Input] UserInput $user): int
    {
        // use $user->email, $user->password, $user->admin

        return 0;
    }
}

This produces the usage:

$ bin/console help app:create-user
Usage:
  app:create-user [options] [--] <email> <password>

Arguments:
  email
  password

Options:
      --admin|--no-admin

(booleans render as --foo/--no-foo as usual)

Nested DTOs (group inputs)

You can compose inputs by nesting DTOs and giving them a form:

class UserInput
{
    #[Argument] 
    public string $email;

    #[Argument] 
    public string $password;

    #[Option]   
    public bool $active = true;

    #[Input]    
    public ProfileInput $profile;
}

class ProfileInput
{
    #[Argument] 
    public string $name;
    
    #[Option]   
    public ?string $phone = null;
}

The resulting signature merges everything:

Usage:
  app:create-user [options] [--] <email> <password> <name>

Arguments:
  email                     
  password                  
  name                      

Options:
      --active|--no-active  
      --phone=PHONE

The resolver walks nested DTOs and fills them accordingly.

Rules & constraints (important)

Same rules as parameter-based #[Argument] / #[Option] apply, plus:

  • Public, non-static properties only. Private/protected/static properties are ignored
  • Constructor is not called. The DTO is instantiated without invoking __construct, values are assigned directly to properties (property hooks run on assignment if present)
  • If an #[Input] class has no #[Argument], #[Option] or nested inputs, that's a logic error

Why this is better

  • One source of truth for command input (definition + mapping live in the DTO)
  • Shorter commands: __invoke(#[Input] Foo $foo) instead of a dozen parameters
  • Composable: group related inputs with nested DTOs
  • Built-in normalization: use property hooks on DTO properties (e.g. lowercase emails)

Future follow-ups (out of scope here)

  • Automatic validation (if Validator is installed) with CLI-friendly violations
  • Delegated interactivity (Command::interact()) to the DTO

Cheers!

@yceruto yceruto requested a review from chalasr as a code owner August 21, 2025 00:24
@carsonbot carsonbot added this to the 7.4 milestone Aug 21, 2025
@yceruto yceruto added the DX DX = Developer eXperience (anything that improves the experience of using Symfony) label Aug 21, 2025
@chalasr
Copy link
Member

chalasr commented Aug 21, 2025

Nice, it was on my list!

@yceruto
Copy link
Member Author

yceruto commented Aug 21, 2025

Nice, it was on my list!

it seems we shared the same list at some point :)

@yceruto yceruto force-pushed the console_input_attribute branch from 1a792fb to 0ed04d7 Compare August 22, 2025 21:53
@yceruto
Copy link
Member Author

yceruto commented Aug 22, 2025

Appreciate the feedback @nicolas-grekas 🙏, all points have been addressed.

@yceruto yceruto force-pushed the console_input_attribute branch 3 times, most recently from 6cdbc96 to 84d092f Compare August 23, 2025 09:00
@yceruto yceruto force-pushed the console_input_attribute branch from 84d092f to 28f49fe Compare August 25, 2025 13:31
Copy link
Member

@chalasr chalasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failures unrelated :shipit:

@yceruto
Copy link
Member Author

yceruto commented Aug 25, 2025

The Fabbot failures are false positives, are there any other failures I might have overlooked?

@yceruto yceruto force-pushed the console_input_attribute branch from 28f49fe to 8727de8 Compare August 27, 2025 13:31
@yceruto
Copy link
Member Author

yceruto commented Aug 27, 2025

Opps, I didn't see the new CS output, it should be ok now. Thanks!

(remaining checks are unrelated or false positives)

Copy link
Member

@fabpot fabpot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks super nice!
Can we read phpdocs to extract a description for each argument/option?

@yceruto yceruto force-pushed the console_input_attribute branch from 8727de8 to 273c507 Compare September 3, 2025 08:23
@yceruto
Copy link
Member Author

yceruto commented Sep 3, 2025

@fabpot Looks super nice! Can we read phpdocs to extract a description for each argument/option?

Yes, absolutely! we can do that. Just to mention though, arguments and options already support a description option that serves the same purpose, do you think that could be enough for your case?

For example:

class UserInput
{
    #[Argument(description: 'The user email address.')]
    public string $email;
}

@yceruto yceruto force-pushed the console_input_attribute branch from 273c507 to 7209cbc Compare September 3, 2025 09:30
@yceruto yceruto force-pushed the console_input_attribute branch from 7209cbc to 14d0f6b Compare September 3, 2025 09:49
@yceruto
Copy link
Member Author

yceruto commented Sep 3, 2025

Thanks for the review! this is ready on my side.

Looking forward to starting work on the follow-up PRs/features :)

Copy link
Contributor

@mtarld mtarld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice one 🚀

@yceruto yceruto force-pushed the console_input_attribute branch from 14d0f6b to 0cff2eb Compare September 6, 2025 12:32
@fabpot
Copy link
Member

fabpot commented Sep 6, 2025

@fabpot Looks super nice! Can we read phpdocs to extract a description for each argument/option?

Yes, absolutely! we can do that. Just to mention though, arguments and options already support a description option that serves the same purpose, do you think that could be enough for your case?

For example:

class UserInput
{
    #[Argument(description: 'The user email address.')]
    public string $email;
}

Indeed, that's good enough.

@fabpot
Copy link
Member

fabpot commented Sep 6, 2025

Thank you @yceruto.

@fabpot fabpot merged commit ae256f9 into symfony:7.4 Sep 6, 2025
6 of 12 checks passed
@yceruto yceruto deleted the console_input_attribute branch September 6, 2025 13:23
fabpot added a commit that referenced this pull request Oct 4, 2025
…ds with `#[Interact]` and `#[Ask]` attributes (yceruto)

This PR was squashed before being merged into the 7.4 branch.

Discussion
----------

[Console] Add support for interactive invokable commands with `#[Interact]` and `#[Ask]` attributes

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

The `#[Interact]` attribute lets you hook into the command *interactive phase* without extending `Command`, and unlocks even more flexibility!

**Characteristics**
- The method marked with `#[Interact]` attribute will be called during interactive mode
- The method must be **public, non‑static**, otherwise a `LogicException` is thrown
- As usual, it runs only when the interactive mode is enabled (e.g. not with `--no-interaction`)
- Supports common helpers and DTOs parameters just like `__invoke()`

**Before:**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand extends Command
{
    protected function interact(InputInterface $input, OutputInterface $output): void
    {
        $io = new SymfonyStyle($input, $output);

        if (!$input->getArgument('username')) {
            $input->setArgument('username', $io->ask('Enter the username'));
        }
    }

    public function __invoke(#[Argument] string $username): int
    {
        // ...
    }
}
```

**After (long version):**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    #[Interact]
    public function prompt(InputInterface $input, SymfonyStyle $io): void
    {
        if (!$input->getArgument('username')) {
            $input->setArgument('username', $io->ask('Enter the username'));
        }
    }

    public function __invoke(#[Argument] string $username): int
    {
        // ...
    }
}
```

This PR also adds the `#[Ask]` attribute for the most basic use cases. It lets you declare interactive prompts directly on parameters. Symfony will automatically ask the user for missing values during the interactive phase, without needing to implement a custom "interact" method yourself:

**After (short version):**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __invoke(
        #[Argument, Ask('Enter the username')]
        string $username,
    ): int {
        // ...
    }
}
```

### DTO‑friendly interaction

In more complex commands, the DTO approach (see PR #61478) lets you work directly with the DTO instance and its properties. You've got three ways to do this, so let's start with the simplest and move toward the most flexible:

#### 1) Attribute-driven interactivity

You can also use the `#[Ask]` attribute on DTO properties that have the `#[Argument]` attribute and no default value; if such a property is unset when running the command (e.g. when the linked argument isn't passed), the component automatically triggers a prompt using your defined question:

```php
class UserDto
{
    #[Argument]
    #[Ask('Enter the username')]
    public string $username;

    #[Argument]
    #[Ask('Enter the password', hidden: true)]
    public string $password;
}

#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __invoke(#[MapInput] UserDto $user): int
    {
        // use $user->username and $user->password
    }
}
```
Example run:
```bash
bin/console app:create-user

 Enter the username:
 > yceruto

 Enter the password:
 > 🔑
```
This makes the most common interactive cases completely declarative.

> [RFC] You may also find other declarative prompts useful, such as #[Choice] (with better support for BackedEnum properties) and #[Confirm] (for bool properties).

#### 2) DTO-driven interactivity

For scenarios that go beyond simple prompts, you can handle interactivity inside the DTO itself. As long as it only concerns the DTO's own properties (and doesn't require external services), you can mark a method with `#[Interact]`. Symfony will call it during the interactive phase, giving you access to helpers to implement custom logic:
```php
class UserDto
{
    #[Argument]
    #[Ask('Enter the username')]
    public string $username;

    #[Argument]
    #[Ask('Enter the password (or press Enter for a random one)', hidden: true)]
    public string $password;

    #[Interact]
    public function prompt(SymfonyStyle $io): void
    {
        if (!isset($this->password)) {
            $this->password = generate_password(10);
            copy_to_clipboard($this->password);
            $io->writeln('Password generated and copied to your clipboard.');
        }
    }
}
```
Yes! `#[Ask]` and `#[Interact]` complement each other and are executed in sequence during the interactive phase.

#### 3) Service‑aware prompts

For cases where prompts depend on external services or need a broader context, you can declare the `#[Interact]` method on the command class itself, giving you full control over the interactive phase:

```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __construct(
        private PasswordStrengthValidatorInterface $validator,
    ) {
    }

    #[Interact]
    public function prompt(SymfonyStyle $io, #[MapInput] UserDto $user): void
    {
        $user->password ??= $io->askHidden('Enter password', $this->validator->isValid(...));
    }

    public function __invoke(#[MapInput] UserDto $user): int
    {
        // ...
    }
}
```
In earlier approaches, you had to set arguments manually with `$input->setArgument()`. With DTOs, you can now work directly on typed properties, which makes the code more expressive and less error-prone.

All three ways can coexist, and the execution order is:
1. `#[Ask]` on `__invoke` parameters
2. `#[Ask]` on DTO properties
3. `#[Interact]` on the DTO class
4. `#[Interact]` on the command class

More related features will be unveiled later.

Cheers!

Commits
-------

6e837c4 [Console] Add support for interactive invokable commands with `#[Interact]` and `#[Ask]` attributes
symfony-splitter pushed a commit to symfony/console that referenced this pull request Oct 4, 2025
…ds with `#[Interact]` and `#[Ask]` attributes (yceruto)

This PR was squashed before being merged into the 7.4 branch.

Discussion
----------

[Console] Add support for interactive invokable commands with `#[Interact]` and `#[Ask]` attributes

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

The `#[Interact]` attribute lets you hook into the command *interactive phase* without extending `Command`, and unlocks even more flexibility!

**Characteristics**
- The method marked with `#[Interact]` attribute will be called during interactive mode
- The method must be **public, non‑static**, otherwise a `LogicException` is thrown
- As usual, it runs only when the interactive mode is enabled (e.g. not with `--no-interaction`)
- Supports common helpers and DTOs parameters just like `__invoke()`

**Before:**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand extends Command
{
    protected function interact(InputInterface $input, OutputInterface $output): void
    {
        $io = new SymfonyStyle($input, $output);

        if (!$input->getArgument('username')) {
            $input->setArgument('username', $io->ask('Enter the username'));
        }
    }

    public function __invoke(#[Argument] string $username): int
    {
        // ...
    }
}
```

**After (long version):**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    #[Interact]
    public function prompt(InputInterface $input, SymfonyStyle $io): void
    {
        if (!$input->getArgument('username')) {
            $input->setArgument('username', $io->ask('Enter the username'));
        }
    }

    public function __invoke(#[Argument] string $username): int
    {
        // ...
    }
}
```

This PR also adds the `#[Ask]` attribute for the most basic use cases. It lets you declare interactive prompts directly on parameters. Symfony will automatically ask the user for missing values during the interactive phase, without needing to implement a custom "interact" method yourself:

**After (short version):**
```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __invoke(
        #[Argument, Ask('Enter the username')]
        string $username,
    ): int {
        // ...
    }
}
```

### DTO‑friendly interaction

In more complex commands, the DTO approach (see PR symfony/symfony#61478) lets you work directly with the DTO instance and its properties. You've got three ways to do this, so let's start with the simplest and move toward the most flexible:

#### 1) Attribute-driven interactivity

You can also use the `#[Ask]` attribute on DTO properties that have the `#[Argument]` attribute and no default value; if such a property is unset when running the command (e.g. when the linked argument isn't passed), the component automatically triggers a prompt using your defined question:

```php
class UserDto
{
    #[Argument]
    #[Ask('Enter the username')]
    public string $username;

    #[Argument]
    #[Ask('Enter the password', hidden: true)]
    public string $password;
}

#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __invoke(#[MapInput] UserDto $user): int
    {
        // use $user->username and $user->password
    }
}
```
Example run:
```bash
bin/console app:create-user

 Enter the username:
 > yceruto

 Enter the password:
 > 🔑
```
This makes the most common interactive cases completely declarative.

> [RFC] You may also find other declarative prompts useful, such as #[Choice] (with better support for BackedEnum properties) and #[Confirm] (for bool properties).

#### 2) DTO-driven interactivity

For scenarios that go beyond simple prompts, you can handle interactivity inside the DTO itself. As long as it only concerns the DTO's own properties (and doesn't require external services), you can mark a method with `#[Interact]`. Symfony will call it during the interactive phase, giving you access to helpers to implement custom logic:
```php
class UserDto
{
    #[Argument]
    #[Ask('Enter the username')]
    public string $username;

    #[Argument]
    #[Ask('Enter the password (or press Enter for a random one)', hidden: true)]
    public string $password;

    #[Interact]
    public function prompt(SymfonyStyle $io): void
    {
        if (!isset($this->password)) {
            $this->password = generate_password(10);
            copy_to_clipboard($this->password);
            $io->writeln('Password generated and copied to your clipboard.');
        }
    }
}
```
Yes! `#[Ask]` and `#[Interact]` complement each other and are executed in sequence during the interactive phase.

#### 3) Service‑aware prompts

For cases where prompts depend on external services or need a broader context, you can declare the `#[Interact]` method on the command class itself, giving you full control over the interactive phase:

```php
#[AsCommand('app:create-user')]
class CreateUserCommand
{
    public function __construct(
        private PasswordStrengthValidatorInterface $validator,
    ) {
    }

    #[Interact]
    public function prompt(SymfonyStyle $io, #[MapInput] UserDto $user): void
    {
        $user->password ??= $io->askHidden('Enter password', $this->validator->isValid(...));
    }

    public function __invoke(#[MapInput] UserDto $user): int
    {
        // ...
    }
}
```
In earlier approaches, you had to set arguments manually with `$input->setArgument()`. With DTOs, you can now work directly on typed properties, which makes the code more expressive and less error-prone.

All three ways can coexist, and the execution order is:
1. `#[Ask]` on `__invoke` parameters
2. `#[Ask]` on DTO properties
3. `#[Interact]` on the DTO class
4. `#[Interact]` on the command class

More related features will be unveiled later.

Cheers!

Commits
-------

6e837c4b1f7 [Console] Add support for interactive invokable commands with `#[Interact]` and `#[Ask]` attributes
This was referenced Oct 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Console DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature Status: Reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants