feature #62567 [Console] Add support for method-based commands (yceruto)

This PR was merged into the 8.1 branch.

Discussion
----------

[Console] Add support for method-based commands

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

Same as with Controllers and Messenger Handlers, this would allow defining multiple commands in the same class:
```php
class MethodBasedCommand
{
    public function __construct(
        // common dependencies ...
    ) {
    }

    #[AsCommand('app:cmd1')]
    public function cmd1(): int
    {
        // ...
    }

    #[AsCommand('app:cmd2')]
    public function cmd2(): int
    {
        // ...
    }
}
```
Asked by `@kbond` in https://github.com/symfony/symfony/pull/59340#pullrequestreview-2526705373

Component standalone usage:
```php
$instance = new MethodBasedCommand();

$application = new Application();
$application->addCommand($instance->cmd1(...)); // or [$instance, 'cmd1']
$application->run($input, $output);

$application = new Application();
$application->addCommand($instance->cmd2(...));
$application->run($input, $output);
```

Testing:
```php
$instance = new MethodBasedCommand();

$tester = new CommandTester($instance->cmd1(...)); // or [$instance, 'cmd1']
$tester->execute([]);
$tester->assertCommandIsSuccessful();

// etc.
```

Commits
-------

6b3ebfb8c08 Add support for method-based commands with AsCommand attribute
This commit is contained in:
Robin Chalas
2025-12-25 04:44:57 +01:00
10 changed files with 305 additions and 106 deletions

View File

@@ -14,7 +14,7 @@ namespace Symfony\Component\Console\Attribute;
/**
* Service tag to autoconfigure commands.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
final class AsCommand
{
/**

View File

@@ -1,6 +1,11 @@
CHANGELOG
=========
8.1
---
* Add support for method-based commands with `#[AsCommand]` attribute
8.0
---

View File

@@ -61,21 +61,14 @@ class Command implements SignalableCommandInterface
*/
public function __construct(?string $name = null, ?callable $code = null)
{
if (null !== $code) {
if (!\is_object($code) || $code instanceof \Closure) {
throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s" or an invokable object.', self::class));
}
/** @var AsCommand $attribute */
$attribute = ((new \ReflectionObject($code))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance()
?? throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class));
$this->setCode($code);
} else {
$attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
}
$this->definition = new InputDefinition();
$attribute = $this->getCommandAttribute($code);
if ($code) {
$this->setCode($code);
}
if (null !== $name ??= $attribute?->name) {
$aliases = explode('|', $name);
@@ -680,4 +673,34 @@ class Command implements SignalableCommandInterface
throw new InvalidArgumentException(\sprintf('Command name "%s" is invalid.', $name));
}
}
private function getCommandAttribute(?callable $code): ?AsCommand
{
if (null === $code) {
/** @var AsCommand|null $attribute */
$attribute = (new \ReflectionClass(static::class)->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
return $attribute;
}
$reflection = new \ReflectionFunction($code(...));
if ($reflection->isAnonymous() || !$class = $reflection->getClosureScopeClass()) {
throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', self::class));
}
/** @var AsCommand|null $attribute */
$attribute = ($reflection->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
if (!$attribute && $reflection->getName() === '__invoke') {
/** @var AsCommand|null $attribute */
$attribute = ($class->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
}
if (!$attribute) {
throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class));
}
return $attribute;
}
}

View File

@@ -162,6 +162,7 @@ class InvokableCommand implements SignalableCommandInterface
OutputInterface::class => $output,
Cursor::class => new Cursor($output),
SymfonyStyle::class => new SymfonyStyle($input, $output),
Command::class => $this->command,
Application::class => $this->command->getApplication(),
default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())),
};

View File

@@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\TypedReference;
@@ -32,12 +33,18 @@ class AddConsoleCommandPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$commandServices = $container->findTaggedServiceIds('console.command', true);
$commandServices = [];
$lazyCommandMap = [];
$lazyCommandRefs = [];
$serviceIds = [];
foreach ($commandServices as $id => $tags) {
foreach ($container->findTaggedServiceIds('console.command', true) as $id => $tags) {
foreach ($tags as $tag) {
$commandServices[$id][$tag['method'] ?? '__invoke'][] = $tag;
}
}
foreach ($commandServices as $id => $commands) {
$definition = $container->getDefinition($id);
$class = $container->getParameterBag()->resolveValue($definition->getClass());
@@ -45,94 +52,8 @@ class AddConsoleCommandPass implements CompilerPassInterface
throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id));
}
if (!$r->isSubclassOf(Command::class)) {
if (!$r->hasMethod('__invoke')) {
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "__invoke()" method.', $id, 'console.command', Command::class));
}
$invokableRef = new Reference($id);
$definition = $container->register($id .= '.command', $class = Command::class)
->addMethodCall('setCode', [$invokableRef]);
} else {
$invokableRef = null;
}
$definition->addTag('container.no_preload');
/** @var AsCommand|null $attribute */
$attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
$defaultName = $attribute?->name;
$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? '');
$aliases = explode('|', $aliases);
$commandName = array_shift($aliases);
if ($isHidden = '' === $commandName) {
$commandName = array_shift($aliases);
}
if (null === $commandName) {
if ($definition->isPrivate() || $definition->hasTag('container.private')) {
$commandId = 'console.command.public_alias.'.$id;
$container->setAlias($commandId, $id)->setPublic(true);
$id = $commandId;
}
$serviceIds[] = $id;
continue;
}
$description = $tags[0]['description'] ?? null;
$help = $tags[0]['help'] ?? null;
$usages = $tags[0]['usages'] ?? null;
unset($tags[0]);
$lazyCommandMap[$commandName] = $id;
$lazyCommandRefs[$id] = new TypedReference($id, $class);
foreach ($aliases as $alias) {
$lazyCommandMap[$alias] = $id;
}
foreach ($tags as $tag) {
if (isset($tag['command'])) {
$aliases[] = $tag['command'];
$lazyCommandMap[$tag['command']] = $id;
}
$description ??= $tag['description'] ?? null;
$help ??= $tag['help'] ?? null;
$usages ??= $tag['usages'] ?? null;
}
$definition->addMethodCall('setName', [$commandName]);
if ($aliases) {
$definition->addMethodCall('setAliases', [$aliases]);
}
if ($isHidden) {
$definition->addMethodCall('setHidden', [true]);
}
if ($help && $invokableRef) {
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
}
if ($usages) {
foreach ($usages as $usage) {
$definition->addMethodCall('addUsage', [$usage]);
}
}
if ($description ??= $attribute?->description) {
$escapedDescription = str_replace('%', '%%', $description);
$definition->addMethodCall('setDescription', [$escapedDescription]);
$container->register('.'.$id.'.lazy', LazyCommand::class)
->setArguments([$commandName, $aliases, $escapedDescription, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
$lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy');
foreach ($commands as $tags) {
$this->registerCommand($container, $r, $id, $class, $tags, $definition, $serviceIds, $lazyCommandMap, $lazyCommandRefs);
}
}
@@ -144,4 +65,129 @@ class AddConsoleCommandPass implements CompilerPassInterface
$container->setParameter('console.command.ids', $serviceIds);
}
private function registerCommand(ContainerBuilder $container, \ReflectionClass $reflection, string $id, string $class, array $tags, Definition $definition, array &$serviceIds, array &$lazyCommandMap, array &$lazyCommandRefs): void
{
if (!$reflection->isSubclassOf(Command::class)) {
$method = $tags[0]['method'] ?? '__invoke';
if (!$reflection->hasMethod($method)) {
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "%s()" method.', $id, 'console.command', Command::class, $method));
}
$reflection = $reflection->getMethod($method);
if (!$reflection->isPublic() || $reflection->isStatic()) {
throw new InvalidArgumentException(\sprintf('The method "%s::%s()" must be public and non-static to be used as a console command.', $class, $method));
}
if ('__invoke' === $method) {
$callableRef = new Reference($id);
$id .= '.command';
} else {
$callableRef = [new Reference($id), $method];
$id .= '.'.$method.'.command';
}
$class = Command::class;
$closureDefinition = new Definition(\Closure::class)
->setFactory([\Closure::class, 'fromCallable'])
->setArguments([$callableRef]);
$definition = $container->register($id, $class)
->addMethodCall('setCode', [$closureDefinition]);
} elseif (isset($tags[0]['method'])) {
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "console.command" cannot define a method command when it is a subclass of "%s".', $id, Command::class));
}
$definition->addTag('container.no_preload');
$attribute = $this->getCommandAttribute($reflection);
$defaultName = $attribute?->name;
$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? '');
$aliases = explode('|', $aliases);
$commandName = array_shift($aliases);
if ($isHidden = '' === $commandName) {
$commandName = array_shift($aliases);
}
if (null === $commandName) {
if ($definition->isPrivate() || $definition->hasTag('container.private')) {
$commandId = 'console.command.public_alias.'.$id;
$container->setAlias($commandId, $id)->setPublic(true);
$id = $commandId;
}
$serviceIds[] = $id;
return;
}
$description = $tags[0]['description'] ?? null;
$help = $tags[0]['help'] ?? null;
$usages = $tags[0]['usages'] ?? null;
unset($tags[0]);
$lazyCommandMap[$commandName] = $id;
$lazyCommandRefs[$id] = new TypedReference($id, $class);
foreach ($aliases as $alias) {
$lazyCommandMap[$alias] = $id;
}
foreach ($tags as $tag) {
if (isset($tag['command'])) {
$aliases[] = $tag['command'];
$lazyCommandMap[$tag['command']] = $id;
}
$description ??= $tag['description'] ?? null;
$help ??= $tag['help'] ?? null;
$usages ??= $tag['usages'] ?? null;
}
$definition->addMethodCall('setName', [$commandName]);
if ($aliases) {
$definition->addMethodCall('setAliases', [$aliases]);
}
if ($isHidden) {
$definition->addMethodCall('setHidden', [true]);
}
if ($help ??= $attribute?->help) {
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
}
if ($usages ??= $attribute?->usages) {
foreach ($usages as $usage) {
$definition->addMethodCall('addUsage', [$usage]);
}
}
if ($description ??= $attribute?->description) {
$escapedDescription = str_replace('%', '%%', $description);
$definition->addMethodCall('setDescription', [$escapedDescription]);
$container->register('.'.$id.'.lazy', LazyCommand::class)
->setArguments([$commandName, $aliases, $escapedDescription, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
$lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy');
}
}
private function getCommandAttribute(\ReflectionClass|\ReflectionMethod $reflection): ?AsCommand
{
/** @var AsCommand|null $attribute */
if ($attribute = ($reflection->getAttributes(AsCommand::class)[0] ?? null)?->newInstance()) {
return $attribute;
}
if ($reflection instanceof \ReflectionMethod && '__invoke' === $reflection->getName()) {
return ($reflection->getDeclaringClass()->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
}
return null;
}
}

View File

@@ -290,9 +290,9 @@ class ApplicationTest extends TestCase
public static function provideInvalidInvokableCommands(): iterable
{
yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)];
yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', Command::class)];
yield 'a closure' => [static function () {
}, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)];
}, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s", an invokable object or a method of an object.', Command::class)];
yield 'without the #[AsCommand] attribute' => [new class {
public function __invoke()
{

View File

@@ -484,6 +484,7 @@ class InvokableCommandTest extends TestCase
Cursor $cursor,
SymfonyStyle $io,
Application $application,
Command $command,
): int {
$this->addToAssertionCount(1);

View File

@@ -19,6 +19,8 @@ use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Console\Tests\Fixtures\MethodBasedTestCommand;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
@@ -233,6 +235,38 @@ class AddConsoleCommandPassTest extends TestCase
$this->assertTrue($container->hasAlias($aliasPrefix.'my-command2'));
}
public function testProcessMultiCommandSameClass()
{
$container = new ContainerBuilder();
$definition = new Definition(MethodBasedTestCommand::class);
$definition->addTag('console.command');
$definition->addTag('console.command', ['method' => 'cmd1']);
$definition->addTag('console.command', ['method' => 'cmd2']);
$container->setDefinition(MethodBasedTestCommand::class, $definition);
new AddConsoleCommandPass()->process($container);
$container->compile();
/** @var ContainerCommandLoader $loader */
$loader = $container->get('console.command_loader');
$this->assertSame(['app:cmd0', 'app:cmd1', 'app:cmd2'], $loader->getNames());
$commandTester = new CommandTester($loader->get('app:cmd0'));
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
$this->assertSame('cmd0', $commandTester->getDisplay());
$commandTester = new CommandTester($loader->get('app:cmd1'));
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
$this->assertSame('cmd1', $commandTester->getDisplay());
$commandTester = new CommandTester($loader->get('app:cmd2'));
$this->assertSame(Command::SUCCESS, $commandTester->execute([]));
$this->assertSame('cmd2', $commandTester->getDisplay());
}
public function testProcessOnChildDefinitionWithClass()
{
$container = new ContainerBuilder();

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Fixtures;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:cmd0')]
class MethodBasedTestCommand
{
public function __invoke(OutputInterface $o): int
{
$o->write('cmd0');
return Command::SUCCESS;
}
#[AsCommand('app:cmd1')]
public function cmd1(OutputInterface $o, #[Argument] ?string $name = null): int
{
$o->write('cmd1');
return Command::SUCCESS;
}
#[AsCommand('app:cmd2')]
public function cmd2(OutputInterface $o): int
{
$o->write('cmd2');
return Command::SUCCESS;
}
}

View File

@@ -31,6 +31,7 @@ use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInteractiveAttributesTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInteractiveHiddenQuestionAttributeTestCommand;
use Symfony\Component\Console\Tests\Fixtures\MethodBasedTestCommand;
class CommandTesterTest extends TestCase
{
@@ -322,6 +323,26 @@ class CommandTesterTest extends TestCase
$tester->assertCommandIsSuccessful();
}
public function testCallableMethodCommands()
{
$command = new MethodBasedTestCommand();
$tester = new CommandTester($command);
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$this->assertSame('cmd0', $tester->getDisplay());
$tester = new CommandTester($command->cmd1(...));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$this->assertSame('cmd1', $tester->getDisplay());
$tester = new CommandTester($command->cmd2(...));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$this->assertSame('cmd2', $tester->getDisplay());
}
public function testInvokableDefinitionWithInputAttribute()
{
$application = new Application();
@@ -353,6 +374,30 @@ class CommandTesterTest extends TestCase
self::assertStringMatchesFormat($expectedOutput, $bufferedOutput->fetch());
}
public function testMethodBasedCommandWithApplication()
{
$command = new MethodBasedTestCommand();
$application = new Application();
$application->addCommand($command->cmd1(...));
$application->setAutoExit(false);
$bufferedOutput = new BufferedOutput();
$statusCode = $application->run(new ArrayInput(['command' => 'help', 'command_name' => 'app:cmd1']), $bufferedOutput);
$expectedOutput = <<<TXT
Usage:
app:cmd1 [<name>]
Arguments:
name %S
%A
TXT;
self::assertSame(0, $statusCode);
self::assertStringMatchesFormat($expectedOutput, $bufferedOutput->fetch());
}
#[DataProvider('getInvokableWithInputData')]
public function testInvokableWithInputAttribute(array $input, string $output)
{