How to debug ANY Symfony command simply passing `-x`
Tags: symfony, xdebug, php, cli
Published: 2024-04-04 16:46:10

Debugging a Symfony console command requires setting some environment variables (depending on your actual configuration of xDebug):

- `XDEBUG_SESSION=1` ([Docs](https://xdebug.org/docs/step_debug#activate-debugger-cmd))
- `XDEBUG_MODE=debug`
- `XDEBUG_ACTIVATED=1`

You can check the purpose of these environment variables on the [xDebug's Docs](https://xdebug.org/docs/all_settings).

# TL;DR

So, launching a Symfony console command in debug mode would look like this:

```console
XDEBUG_SESSION=1 XDEBUG_MODE=debug XDEBUG_ACTIVATED=1 php bin/console my:command --an-option --an argument
```

Using the listener below, instead, you can debug any Symfony command this way:

```console
bin/console my:command --an-option --an argument -x
```

Really shorter and faster, also to debug a command on the fly, without having to move the cursor to the beginning of the command (that is so boring to do on the CLI! 🤬).

The `-x` option starts the "magic" and the listener actually performs the trick.

Using this listener, you can actually debug ANY Symfony command, also if it doesn't belong to your app, but belongs to, for example, Doctrine, a third party bundle or even Symfony itself.

It's really like magic!

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nc4h3onhognc8aktz99g.png)

# The `RunCommandInDebugModeEventListener` listener

The listener works thanks to the [`ConsoleEvents::COMMAND` event](https://symfony.com/doc/current/components/console/events.html).

It simply searches for the flag `-x` (or `--xdebug`) and, if it finds it, then restarts the command setting the environment variables required by xDebug to work.

The restart of the command is done through the [PHP function `passthru()`](https://www.php.net/manual/en/function.passthru.php).

The rest of the code is sufficiently self explanatory, so I'm not going to explain it.

This is the listener: happy Symfony commands debugging!

```php
<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\HelpCommand;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(ConsoleEvents::COMMAND, 'configure')]
class RunCommandInDebugModeEventListener
{
    public function configure(ConsoleCommandEvent $event): void
    {
        $command = $event->getCommand();

        if (false === $command instanceof Command) {
            throw new \RuntimeException('The command must be an instance of ' . Command::class);
        }

        if ($command instanceof HelpCommand) {
            $command = $this->getActualCommandFromHelpCommand($command);
        }

        $command->addOption(
            name: 'xdebug',
            shortcut: 'x',
            mode: InputOption::VALUE_NONE,
            description: 'If passed, the command is re-run setting env variables required by xDebug.',
        );

        if ($command instanceof HelpCommand) {
            return;
        }

        $input = $event->getInput();
        if (false === $input instanceof ArgvInput) {
            return;
        }

        if (false === $this->isInDebugMode($input)) {
            return;
        }

        if ('1' === getenv('XDEBUG_SESSION')) {
            return;
        }

        $output = $event->getOutput();
        $output->writeln('<comment>Relaunching the command with xDebug...</comment>');

        $cmd = $this->buildCommandWithXDebugActivated();

        \passthru($cmd);
        exit;
    }

    private function getActualCommandFromHelpCommand(HelpCommand $command): Command
    {
        $reflection    = new \ReflectionClass($command);
        $property      = $reflection->getProperty('command');
        $actualCommand = $property->getValue($command);

        if (false === $actualCommand instanceof Command) {
            throw new \RuntimeException('The command must be an instance of ' . Command::class);
        }

        return $actualCommand;
    }

    private function isInDebugMode(ArgvInput $input): bool
    {
        $tokens = $this->getTokensFromArgvInput($input);

        foreach ($tokens as $token) {
            if ('--xdebug' === $token || '-x' === $token) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string>
     */
    private function getTokensFromArgvInput(ArgvInput $input): array
    {
        $reflection     = new \ReflectionClass($input);
        $tokensProperty = $reflection->getProperty('tokens');
        $tokens         = $tokensProperty->getValue($input);

        if (false === is_array($tokens)) {
            throw new \RuntimeException('Impossible to get the arguments and options from the command.');
        }

        return $tokens;
    }

    private function buildCommandWithXDebugActivated(): string
    {
        $serverArgv = $_SERVER['argv'] ?? null;
        if (null === $serverArgv) {
            throw new \RuntimeException('Impossible to get the arguments and options from the command: the command cannot be relaunched with xDebug.');
        }

        $script = $_SERVER['SCRIPT_NAME'] ?? null;
        if (null === $script) {
            throw new \RuntimeException('Impossible to get the name of the command: the command cannot be relaunched with xDebug.');
        }

        $phpBinary = PHP_BINARY;
        $args      = implode(' ', array_slice($serverArgv, 1));

        return "XDEBUG_SESSION=1 XDEBUG_MODE=debug XDEBUG_ACTIVATED=1 {$phpBinary} {$script} {$args}";
    }
}
```