Skip to content

Promote Invokable Commands #1580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 38 additions & 59 deletions src/Command/AddUserCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
use App\Repository\UserRepository;
use App\Utils\Validator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
Expand Down Expand Up @@ -49,7 +49,8 @@
*/
#[AsCommand(
name: 'app:add-user',
description: 'Creates users and stores them in the database'
description: 'Creates users and stores them in the database',
help: self::HELP,
)]
final class AddUserCommand extends Command
Copy link
Member

Choose a reason for hiding this comment

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

should probably not extend Command here if you want to showcase the simplest usage of invokable commands.

Copy link
Member Author

Choose a reason for hiding this comment

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

This one is still not possible without extending from Command as we're showcasing the initialize and interact methods.

It’s the ListUsersCommand that showcases the simplest usage

{
Expand All @@ -64,23 +65,9 @@ public function __construct(
parent::__construct();
}

protected function configure(): void
{
$this
->setHelp($this->getCommandHelp())
// commands can optionally define arguments and/or options (mandatory and optional)
// see https://symfony.com/doc/current/components/console/console_arguments.html
->addArgument('username', InputArgument::OPTIONAL, 'The username of the new user')
->addArgument('password', InputArgument::OPTIONAL, 'The plain password of the new user')
->addArgument('email', InputArgument::OPTIONAL, 'The email of the new user')
->addArgument('full-name', InputArgument::OPTIONAL, 'The full name of the new user')
->addOption('admin', null, InputOption::VALUE_NONE, 'If set, the user is created as an administrator')
;
}

/**
* This optional method is the first one executed for a command after configure()
* and is useful to initialize properties based on the input arguments and options.
* This optional method is the first one executed for a command and is useful
* to initialize properties based on the input arguments and options.
*/
protected function initialize(InputInterface $input, OutputInterface $output): void
{
Expand All @@ -91,9 +78,9 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
}

/**
* This method is executed after initialize() and before execute(). Its purpose
* is to check if some of the options/arguments are missing and interactively
* ask the user for those values.
* This method is executed after initialize() and before __invoke(). Its purpose
* is to check if some options/arguments are missing and interactively ask the user
* for those values.
*
* This method is completely optional. If you are developing an internal console
* command, you probably should not implement this method because it requires
Expand Down Expand Up @@ -161,26 +148,21 @@ protected function interact(InputInterface $input, OutputInterface $output): voi
/**
* This method is executed after interact() and initialize(). It usually
* contains the logic to execute to complete this command task.
*
* Commands can optionally define arguments and/or options (mandatory and optional)
*
* @see https://symfony.com/doc/current/console/input.html
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
public function __invoke(
#[Argument('The username of the new user')] string $username,
#[Argument('The plain password of the new user', 'password')] string $plainPassword,
#[Argument('The email of the new user')] string $email,
#[Argument('The full name of the new user')] string $fullName,
#[Option('If set, the user is created as an administrator', 'admin')] bool $isAdmin = false,
): int {
$stopwatch = new Stopwatch();
$stopwatch->start('add-user-command');

/** @var string $username */
$username = $input->getArgument('username');

/** @var string $plainPassword */
$plainPassword = $input->getArgument('password');

/** @var string $email */
$email = $input->getArgument('email');

/** @var string $fullName */
$fullName = $input->getArgument('full-name');

$isAdmin = $input->getOption('admin');

// make sure to validate the user data is correct
$this->validateUserData($username, $plainPassword, $email, $fullName);

Expand All @@ -202,7 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$event = $stopwatch->stop('add-user-command');

if ($output->isVerbose()) {
if ($this->io->isVerbose()) {
$this->io->comment(\sprintf('New user database id: %d / Elapsed time: %.2f ms / Consumed memory: %.2f MB', $user->getId(), $event->getDuration(), $event->getMemory() / (1024 ** 2)));
}

Expand Down Expand Up @@ -232,33 +214,30 @@ private function validateUserData(string $username, string $plainPassword, strin
}

/**
* The command help is usually included in the configure() method, but when
* it's too long, it's better to define a separate method to maintain the
* The command help is usually included in the #[AsCommand] attribute, but when
* it's too long, it's better to define a separate constant to maintain the
* code readability.
*/
private function getCommandHelp(): string
{
return <<<'HELP'
The <info>%command.name%</info> command creates new users and saves them in the database:
public const HELP = <<<'HELP'
The <info>%command.name%</info> command creates new users and saves them in the database:

<info>php %command.full_name%</info> <comment>username password email</comment>
<info>php %command.full_name%</info> <comment>username password email</comment>

By default the command creates regular users. To create administrator users,
add the <comment>--admin</comment> option:
By default the command creates regular users. To create administrator users,
add the <comment>--admin</comment> option:

<info>php %command.full_name%</info> username password email <comment>--admin</comment>
<info>php %command.full_name%</info> username password email <comment>--admin</comment>

If you omit any of the three required arguments, the command will ask you to
provide the missing values:
If you omit any of the three required arguments, the command will ask you to
provide the missing values:

# command will ask you for the email
<info>php %command.full_name%</info> <comment>username password</comment>
# command will ask you for the email
<info>php %command.full_name%</info> <comment>username password</comment>

# command will ask you for the email and password
<info>php %command.full_name%</info> <comment>username</comment>
# command will ask you for the email and password
<info>php %command.full_name%</info> <comment>username</comment>

# command will ask you for all arguments
<info>php %command.full_name%</info>
HELP;
}
# command will ask you for all arguments
<info>php %command.full_name%</info>
HELP;
}
35 changes: 13 additions & 22 deletions src/Command/DeleteUserCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
use App\Utils\Validator;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
Expand All @@ -41,7 +41,17 @@
*/
#[AsCommand(
name: 'app:delete-user',
description: 'Deletes users from the database'
description: 'Deletes users from the database',
help: <<<'HELP'
The <info>%command.name%</info> command deletes users from the database:

<info>php %command.full_name%</info> <comment>username</comment>

If you omit the argument, the command will ask you to
provide the missing value:

<info>php %command.full_name%</info>
HELP,
)]
final class DeleteUserCommand extends Command
{
Expand All @@ -56,23 +66,6 @@ public function __construct(
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'The username of an existing user')
->setHelp(<<<'HELP'
The <info>%command.name%</info> command deletes users from the database:

<info>php %command.full_name%</info> <comment>username</comment>

If you omit the argument, the command will ask you to
provide the missing value:

<info>php %command.full_name%</info>
HELP
);
}

protected function initialize(InputInterface $input, OutputInterface $output): void
{
// SymfonyStyle is an optional feature that Symfony provides so you can
Expand Down Expand Up @@ -105,10 +98,8 @@ protected function interact(InputInterface $input, OutputInterface $output): voi
$input->setArgument('username', $username);
}

protected function execute(InputInterface $input, OutputInterface $output): int
public function __invoke(#[Argument('The username of an existing user')] string $username): int
{
/** @var string|null $username */
$username = $input->getArgument('username');
$username = $this->validator->validateUsername($username);

/** @var User|null $user */
Expand Down
66 changes: 28 additions & 38 deletions src/Command/ListUsersCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
Expand All @@ -42,54 +42,47 @@
#[AsCommand(
name: 'app:list-users',
description: 'Lists all the existing users',
aliases: ['app:users']
aliases: ['app:users'],
help: <<<'HELP'
The <info>%command.name%</info> command lists all the users registered in the application:

<info>php %command.full_name%</info>

By default the command only displays the 50 most recent users. Set the number of
results to display with the <comment>--max-results</comment> option:

<info>php %command.full_name%</info> <comment>--max-results=2000</comment>

In addition to displaying the user list, you can also send this information to
the email address specified in the <comment>--send-to</comment> option:

<info>php %command.full_name%</info> <comment>[email protected]</comment>
HELP,
)]
final class ListUsersCommand extends Command
final class ListUsersCommand
{
public function __construct(
private readonly MailerInterface $mailer,
#[Autowire('%app.notifications.email_sender%')]
private readonly string $emailSender,
private readonly UserRepository $users,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->setHelp(<<<'HELP'
The <info>%command.name%</info> command lists all the users registered in the application:

<info>php %command.full_name%</info>

By default the command only displays the 50 most recent users. Set the number of
results to display with the <comment>--max-results</comment> option:

<info>php %command.full_name%</info> <comment>--max-results=2000</comment>

In addition to displaying the user list, you can also send this information to
the email address specified in the <comment>--send-to</comment> option:

<info>php %command.full_name%</info> <comment>[email protected]</comment>
HELP
)
// commands can optionally define arguments and/or options (mandatory and optional)
// see https://symfony.com/doc/current/components/console/console_arguments.html
->addOption('max-results', null, InputOption::VALUE_OPTIONAL, 'Limits the number of users listed', 50)
->addOption('send-to', null, InputOption::VALUE_OPTIONAL, 'If set, the result is sent to the given email address')
;
}

/**
* This method is executed after initialize(). It usually contains the logic
* to execute to complete this command task.
*
* Commands can optionally define arguments and/or options (mandatory and optional)
*
* @see https://symfony.com/doc/current/console/input.html
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var int|null $maxResults */
$maxResults = $input->getOption('max-results');

public function __invoke(
InputInterface $input,
OutputInterface $output,
#[Option('If set, the result is sent to the given email address', 'send-to')] ?string $email = null,
#[Option('Limits the number of users listed')] int $maxResults = 50,
): int {
// Use ->findBy() instead of ->findAll() to allow result sorting and limiting
$allUsers = $this->users->findBy([], ['id' => 'DESC'], $maxResults);

Expand Down Expand Up @@ -122,9 +115,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$usersAsATable = $bufferedOutput->fetch();
$output->write($usersAsATable);

/** @var string|null $email */
$email = $input->getOption('send-to');

if (null !== $email) {
$this->sendReport($usersAsATable, $email);
}
Expand Down
Empty file removed src/Controller/.gitignore
Empty file.
Empty file removed src/Entity/.gitignore
Empty file.
Empty file removed src/Repository/.gitignore
Empty file.
10 changes: 4 additions & 6 deletions tests/Command/AbstractCommandTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

abstract class AbstractCommandTestCase extends KernelTestCase
Expand All @@ -29,11 +28,10 @@ abstract class AbstractCommandTestCase extends KernelTestCase
protected function executeCommand(array $arguments, array $inputs = []): CommandTester
{
$kernel = self::bootKernel();
$application = new Application($kernel);

// this uses a special testing container that allows you to fetch private services
/** @var Command $command */
$command = static::getContainer()->get($this->getCommandFqcn());
$command->setApplication(new Application($kernel));
$command = $application->find($this->getCommandName());
$command->setApplication($application);

$commandTester = new CommandTester($command);
$commandTester->setInputs($inputs);
Expand All @@ -42,5 +40,5 @@ protected function executeCommand(array $arguments, array $inputs = []): Command
return $commandTester;
}

abstract protected function getCommandFqcn(): string;
abstract protected function getCommandName(): string;
}
5 changes: 2 additions & 3 deletions tests/Command/AddUserCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace App\Tests\Command;

use App\Command\AddUserCommand;
use App\Repository\UserRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
Expand Down Expand Up @@ -103,8 +102,8 @@ private function assertUserCreated(bool $isAdmin): void
$this->assertSame($isAdmin ? ['ROLE_ADMIN'] : ['ROLE_USER'], $user->getRoles());
}

protected function getCommandFqcn(): string
protected function getCommandName(): string
{
return AddUserCommand::class;
return 'app:add-user';
}
}
5 changes: 2 additions & 3 deletions tests/Command/ListUsersCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace App\Tests\Command;

use App\Command\ListUsersCommand;
use PHPUnit\Framework\Attributes\DataProvider;

final class ListUsersCommandTest extends AbstractCommandTestCase
Expand Down Expand Up @@ -50,8 +49,8 @@ public function testItSendsAnEmailIfOptionProvided(): void
$this->assertEmailCount(1);
}

protected function getCommandFqcn(): string
protected function getCommandName(): string
{
return ListUsersCommand::class;
return 'app:list-users';
}
}