MidТеория6 min

События и Verbosity

ConsoleEvents, уровни verbosity, обработка сигналов

Console Events

Console-компонент генерирует события на каждом этапе выполнения команды. Это позволяет перехватывать и модифицировать поведение любой команды.

Список событий

Событие Когда вызывается
ConsoleEvents::COMMAND Перед выполнением команды
ConsoleEvents::TERMINATE После выполнения команды
ConsoleEvents::ERROR При возникновении исключения
ConsoleEvents::SIGNAL При получении OS-сигнала (SIGINT, SIGTERM)

ConsoleEvents::COMMAND

Вызывается перед execute(). Позволяет модифицировать ввод, вывод или полностью отменить выполнение.

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class CommandLoggingSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            ConsoleEvents::COMMAND => 'onCommand',
        ];
    }

    public function onCommand(ConsoleCommandEvent $event): void
    {
        $command = $event->getCommand();
        $input = $event->getInput();

        $this->logger->info('Executing command: {name}', [
            'name' => $command?->getName(),
            'arguments' => $input->getArguments(),
            'options' => $input->getOptions(),
        ]);

        // Optionally disable the command
        // $event->disableCommand();
        // When disabled, execute() is NOT called, return code = COMMAND::FAILURE
    }
}

ConsoleEvents::TERMINATE

Вызывается после завершения команды (после execute()). Позволяет выполнить cleanup или изменить код возврата.

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class CommandTimingSubscriber implements EventSubscriberInterface
{
    private float $startTime = 0.0;

    public static function getSubscribedEvents(): array
    {
        return [
            ConsoleEvents::COMMAND => ['onCommand', 255],
            ConsoleEvents::TERMINATE => ['onTerminate', -255],
        ];
    }

    public function onCommand(): void
    {
        $this->startTime = microtime(true);
    }

    public function onTerminate(ConsoleTerminateEvent $event): void
    {
        $duration = microtime(true) - $this->startTime;
        $command = $event->getCommand();
        $exitCode = $event->getExitCode();

        $output = $event->getOutput();
        $output->writeln('');
        $output->writeln(sprintf(
            '<comment>Command "%s" finished in %.2fs (exit code: %d)</comment>',
            $command?->getName() ?? 'unknown',
            $duration,
            $exitCode,
        ));

        // Optionally change exit code
        // $event->setExitCode(0);
    }
}

ConsoleEvents::ERROR

Вызывается при исключении в execute(). Позволяет логировать ошибку, изменить исключение или код возврата.

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class CommandErrorSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            ConsoleEvents::ERROR => 'onError',
        ];
    }

    public function onError(ConsoleErrorEvent $event): void
    {
        $error = $event->getError();
        $command = $event->getCommand();

        $this->logger->error('Command "{name}" threw exception: {message}', [
            'name' => $command?->getName(),
            'message' => $error->getMessage(),
            'trace' => $error->getTraceAsString(),
        ]);

        // Change the exit code
        $event->setExitCode(1);

        // Replace the exception (optional)
        // $event->setError(new \RuntimeException('Wrapped: ' . $error->getMessage(), 0, $error));
    }
}

ConsoleEvents::SIGNAL

Обработка OS-сигналов (SIGINT при Ctrl+C, SIGTERM при остановке).

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:long-task')]
final class LongTaskCommand extends Command implements SignalableCommandInterface
{
    private bool $shouldStop = false;

    /**
     * List of signals this command handles
     * @return list<int>
     */
    public function getSubscribedSignals(): array
    {
        return [\SIGINT, \SIGTERM];
    }

    /**
     * Handle the received signal
     */
    public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
    {
        match ($signal) {
            \SIGINT => $this->shouldStop = true,
            \SIGTERM => $this->shouldStop = true,
            default => null,
        };

        // Return false to continue, or exit code to stop
        return false; // Continue running, but flag is set
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $output->writeln('Processing... Press Ctrl+C to stop gracefully.');

        for ($i = 0; $i < 1000; $i++) {
            if ($this->shouldStop) {
                $output->writeln('<comment>Graceful shutdown requested</comment>');
                // Cleanup resources
                break;
            }

            $output->write('.');
            usleep(100000);
        }

        $output->writeln('');
        $output->writeln('<info>Done!</info>');

        return Command::SUCCESS;
    }
}

Verbosity Levels

Уровни verbosity контролируют, сколько информации выводит команда.

Уровни

Уровень Значение Флаг Использование
QUIET 16 -q Без вывода
NORMAL 32 (по умолчанию) Стандартный вывод
VERBOSE 64 -v Подробности
VERY_VERBOSE 128 -vv Ещё больше подробностей
DEBUG 256 -vvv Отладочная информация

Использование verbosity

<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:verbosity-demo')]
final class VerbosityDemoCommand extends Command
{
    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        // Method 1: Check verbosity level
        if ($output->isQuiet()) {
            // -q: no output at all
            return Command::SUCCESS;
        }

        // Always shown (NORMAL and above)
        $output->writeln('Processing started...');

        if ($output->isVerbose()) {
            // -v: additional details
            $output->writeln('Loading configuration...');
            $output->writeln('Connecting to database...');
        }

        if ($output->isVeryVerbose()) {
            // -vv: even more details
            $output->writeln('SQL: SELECT * FROM users WHERE active = 1');
            $output->writeln('Found 42 users');
        }

        if ($output->isDebug()) {
            // -vvv: debug information
            $output->writeln('Memory usage: ' . memory_get_usage(true));
            $output->writeln('Time elapsed: 0.42s');
        }

        $output->writeln('Done!');

        return Command::SUCCESS;
    }
}

Verbosity с OutputInterface constants

<?php

declare(strict_types=1);

use Symfony\Component\Console\Output\OutputInterface;

// Method 2: writeln with verbosity parameter
$output->writeln('Always visible');
$output->writeln('Verbose info', OutputInterface::VERBOSITY_VERBOSE);
$output->writeln('Very verbose', OutputInterface::VERBOSITY_VERY_VERBOSE);
$output->writeln('Debug info', OutputInterface::VERBOSITY_DEBUG);

Verbosity с SymfonyStyle

<?php

declare(strict_types=1);

$io = new SymfonyStyle($input, $output);

// SymfonyStyle checks verbosity automatically for some methods:
// - note(), caution() shown at VERBOSE and above
// - writeln() always shown unless QUIET

// Explicit check
if ($io->isVerbose()) {
    $io->note('Detailed processing information');
}

if ($io->isDebug()) {
    $io->text('Debug: memory=' . memory_get_usage());
}

Полный пример: команда с событиями и verbosity

<?php

declare(strict_types=1);

namespace App\Command;

use App\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:cleanup-users',
    description: 'Remove inactive users from the database',
)]
final class CleanupUsersCommand extends Command implements SignalableCommandInterface
{
    private bool $shouldStop = false;

    public function __construct(
        private readonly UserRepository $userRepository,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not actually delete')
            ->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Days of inactivity', '90');
    }

    public function getSubscribedSignals(): array
    {
        return [\SIGINT, \SIGTERM];
    }

    public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
    {
        $this->shouldStop = true;
        return false;
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $io = new SymfonyStyle($input, $output);
        $dryRun = $input->getOption('dry-run');
        $days = (int) $input->getOption('days');

        $io->title('User Cleanup');

        if ($dryRun) {
            $io->warning('DRY RUN MODE -- no changes will be made');
        }

        $users = $this->userRepository->findInactiveUsers($days);
        $count = count($users);

        if ($count === 0) {
            $io->success('No inactive users found');
            return Command::SUCCESS;
        }

        $io->text(sprintf('Found %d inactive users (>%d days)', $count, $days));
        $io->progressStart($count);

        $deleted = 0;
        foreach ($users as $user) {
            if ($this->shouldStop) {
                $io->progressFinish();
                $io->warning("Interrupted! Deleted $deleted/$count users");
                return Command::SUCCESS;
            }

            if ($output->isVerbose()) {
                $io->text(sprintf(
                    'Deleting: %s (last login: %s)',
                    $user->getEmail(),
                    $user->getLastLoginAt()?->format('Y-m-d') ?? 'never',
                ));
            }

            if (!$dryRun) {
                $this->userRepository->remove($user);
                $deleted++;
            }

            $io->progressAdvance();
        }

        $io->progressFinish();

        if ($dryRun) {
            $io->note("Would have deleted $count users");
        } else {
            $io->success("Deleted $deleted users");
        }

        if ($output->isVeryVerbose()) {
            $io->text(sprintf(
                'Memory: %s, Time: %.2fs',
                number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . 'MB',
                microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'],
            ));
        }

        return Command::SUCCESS;
    }
}

Итоги

  • ConsoleEvents::COMMAND -- перед выполнением (можно отменить)
  • ConsoleEvents::TERMINATE -- после выполнения (можно изменить exit code)
  • ConsoleEvents::ERROR -- при исключении (логирование, замена ошибки)
  • ConsoleEvents::SIGNAL / SignalableCommandInterface -- OS-сигналы (Ctrl+C)
  • Verbosity: QUIET < NORMAL < VERBOSE < VERY_VERBOSE < DEBUG
  • isVerbose(), isVeryVerbose(), isDebug() -- проверки уровня
  • -q, -v, -vv, -vvv -- флаги командной строки