Extend your Symfony Console app with events and attributes

Fernando Castillo
6 min readJun 1, 2024

--

You can easily build a console application using the Symfony Console component, but did you know that you could extend it just as easily?

Using events in your console application you can add interactivity or output that applies to all your commands, or use PHP attributes to add controlled behaviors to specific ones.

We’ll see two examples and you can follow the code in this github repository: https://github.com/fcastilloes/console-example. In there, you can see a simple console application with two commands, Foo and Bar, both of which do only one thing: say hello.

But if we execute them both, we will see something else. First, both are adding an extra info output: “Working in dev environment”. And the bar command is also adding a confirm interaction before executing the rest of the command. But none of those are in the command code. Let’s see how it’s done.

Using the environment

One important aspect for these examples is the concept of the environment. In the previous example we’ve seen that running the console will use the dev environment, but like in a Symfony app, we can use the APP_ENV variable to instruct the console to run in a different environment.

If you analyze the output from each command in each environment you can see more differences:

  • Foo in qa works and prints it’s working in qa environment.
  • Bar in qa shows an error that the command is not compatible with qa environment.
  • Foo in prod asks for confirmation.
  • Bar in prod shows an error that the command is not allowed in prod environment.

We can quickly see how the environment selection is implemented before going into those differences in behavior. The code is in bin/console.php, and looks like this:

$env = getenv('APP_ENV') ?: 'dev';
$application = new Application();

$container = new ContainerBuilder();
$loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../src'));
$loader->import(__DIR__ . "/../config/services_{$env}.php");

The important part is getting getenv('APP_ENV') to take the value we set in the command line, with dev as default. Then we use that value to load a specific service file, like this one, which actually sets theenv parameter before loading the general service file that applies to all environments.

// config/services_dev.php
<?php

declare(strict_types=1);

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $container): void {
$container->parameters()->set('env', 'dev');
$container->import(__DIR__ . '/services.php');
};

Show information after every command

When working with a console application that accepts different environments, it can be nice to get some output that confirms in which environment the command was executed. If we do this we could gather more information to show, but let’s keep it simple.

We are going to add a listener to the ConsoleEvents:TERMINATE event, which is dispatched after the command has been executed. The listener’s code is very simple.

<?php

declare(strict_types=1);

namespace App\Console\Listeners;

use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\Console\Style\SymfonyStyle;

final readonly class OutputEnvironmentOnTerminationListener
{
public function __construct(
private string $env,
) {}

public function __invoke(ConsoleTerminateEvent $event): void
{
$io = new SymfonyStyle($event->getInput(), $event->getOutput());

$io->info("Working in {$this->env} environment");
}
}

If we configured the container to bind $env to the environment value, we can just get it in the constructor, so the only thing we need to do is output it.

Control execution based on attributes

If your console application can be executed in different environments, there is a chance that the implications of executing a command are different depending on the environment. So it could be useful if we could define some commands in a way that we are asked for confirmation before executing it in some environment, and even some commands to be forbidden in another. Think about operations like clearing data from a database for testing, in which you may execute it routinely in development, and sometimes the qa team could ask you to run it in their environment. You probably want to be asked for confirmation if running it in qa because a mistake there could ruin the work of another team. And of course, that kind of operation is really dangerous to be executed in production, so that should be forbidden.

To support this we are creating two attribute classes which will be handled with another listener.

<?php

declare(strict_types=1);

namespace App\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class ConfirmEnvironmentAttribute
{
public string $message;

public function __construct(
public string $env,
string $message = 'Do you want to run the command in {{env}} environment.',
) {
$this->message = str_replace('{{env}}', $env, $message);
}
}
<?php

declare(strict_types=1);

namespace App\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class ForbidEnvironmentAttribute
{
public string $message;

public function __construct(
public string $env,
string $message = 'This command is not allowed in {{env}} environment.',
) {
$this->message = str_replace('{{env}}', $env, $message);
}
}

These attributes are similar, they get an environment as a string, and a message with a default value, which acts as a template in which the environment can be replaced. Let’s take a look at the listener now.

<?php

declare(strict_types=1);

namespace App\Console\Listeners;

use App\Attribute\ConfirmEnvironmentAttribute;
use App\Attribute\ForbidEnvironmentAttribute;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Style\SymfonyStyle;

final readonly class ControlExecutionOnCommandListener
{
public function __construct(
private string $env,
) {}

public function __invoke(ConsoleCommandEvent $event): void
{
$io = new SymfonyStyle($event->getInput(), $event->getOutput());

$command = $event->getCommand();
$reflection = new \ReflectionObject($command);

$attributes = $reflection->getAttributes(ForbidEnvironmentAttribute::class);

foreach ($attributes as $attribute) {
/** @var ForbidEnvironmentAttribute $forbidEnvironment */
$forbidEnvironment = $attribute->newInstance();

if ($forbidEnvironment->env !== $this->env) {
continue;
}

$io->error($forbidEnvironment->message);
exit(Command::FAILURE);
}

$attributes = $reflection->getAttributes(ConfirmEnvironmentAttribute::class);

foreach ($attributes as $attribute) {
/** @var ConfirmEnvironmentAttribute $confirmEnvironment */
$confirmEnvironment = $attribute->newInstance();

if ($confirmEnvironment->env !== $this->env) {
continue;
}

$confirm = $io->confirm($confirmEnvironment->message, false);

if (!$confirm) {
$io->info('User cancelled the operation.');
exit(Command::SUCCESS);
}
}
}
}

This one is a bit longer, but not much more complex, and doing something similar for the two events we are handling. Let’s see each part.

Fetch the attributes

$command = $event->getCommand();
$reflection = new \ReflectionObject($command);

$attributes = $reflection->getAttributes(ForbidEnvironmentAttribute::class);

We use the reflection object to get the attributes of the class. At this point, the $attributes variable has an array of ReflectionAttribute objects, not the actual attributes, but we already filtered by ForbidEnvironmentAttribute.

Execute the action

foreach ($attributes as $attribute) {
/** @var ForbidEnvironmentAttribute $forbidEnvironment */
$forbidEnvironment = $attribute->newInstance();

if ($forbidEnvironment->env !== $this->env) {
continue;
}

$io->error($forbidEnvironment->message);
exit(Command::FAILURE);
}

For each one of the attributes we found for the command, we need to get an instance. The instance will have the values defined in the command, so we can use those in the implementation.

We use now the attribute to make sure we are in the correct environment to apply the execution control, and if we are, we apply the action, which for the ForbidEnvironmentAttribute it is writing an error to the output and exit with failure.

If the matched attribute is ConfirmEnvironmentAttribute, the logic is similar, but we ask for confirmation instead of directly error and exit.

Usage in the commands

The first listener will do its job for every command and print the environment in the output, but the second one depends on the attributes applied to the commands, so the last step is adding them.

#[ConfirmEnvironmentAttribute('prod')]
final class FooCommand extends Command
{
// ...
}

For the Foo command there is only one attribute, that makes sure there is a confirmation when executed in the prod environment, using the default message.

#[ForbidEnvironmentAttribute('prod')]
#[ForbidEnvironmentAttribute('qa', message: 'This command is not compatible with the qa environment.')]
#[ConfirmEnvironmentAttribute('dev', message: 'Confirm you know what you are doing.')]
final class BarCommand extends Command
{
// ...
}

In the Bar command we are using both attributes, and one of them is used twice, so it’s important that the attribute definition includes the Attribute::IS_REPEATABLE flag or it will fail.

Doing it this way, the execution control will behave differently for each environment, asking for confirmation in dev, forbidding in qa with a custom message, and forbidding in prod with the default message.

In the end, using the events from Symfony we can extend the console applications to our needs, and combining them with PHP attributes we can control every detail we need about these extensions for each command.

--

--