3min.

Handling signal with Symfony Command

A few years ago, we wrote an article (in french) about how POSIX signals work in PHP.

Today, we want to share with you how to handle signals with Symfony Command.

⚠ This works only as of Symfony 6.3. Symfony 6.3 will be released in May 2023.

By default, Symfony Command does not handle signals. So when you start a command, and you hit CTRL+C it will immediately stop the process.

Most of the time it’s safe, but sometimes you want to handle signals to do some cleanup before stopping the command. For example if your command dialogs with a remote payment API, you want to write an atomic transaction: either the payment (remotely + locally) is done, or it is not.

Section intitulée how-to-handle-signalsHow to handle signals

To handle signals, you need to implement the SignalableCommandInterface:

<?php

namespace App\Command;

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

class PaymentCommand extends Command implements SignalableCommandInterface
{
    private bool $shouldStop = false;

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        foreach ($this->getPayments() as $payment) {
            if ($this->shouldStop) {
                break;
            }

            $this->processPayment($payment);
        }

        return Command::SUCCESS;
    }

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

    public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
    {
        $this->logger->info('Signal received, stopping the command...', [
            'signal' => $signal,
        ]);
        $this->shouldStop = true;

        return false;
    }
}

As soon as the process is interrupted with SIGINT or SIGTERM, the handleSignal() method is called. This method will toggle the shouldStop property to true. Then, the execute() method is resumed and stops the loop, but only after the current payment has been fully processed fully.

The return false; line allows to return a specific exit code when the command is signaled, or to not exit at all. In our case, we do not want to automatically exit on SIGINT or SIGTERM, so we return false.

Section intitulée how-to-use-event-to-handle-signalsHow to use Event to handle signals

Symfony emits an event when a signal is received. By default the following signals are handled:

  • SIGINT (Ctrl+C)
  • SIGTERM (kill)
  • SIGUSR1 (kill -USR1)
  • SIGUSR2 (kill -USR2)

You can listen to the ConsoleEvents::SIGNAL event to handle signals. The following example shows what you can do in a subscriber:

<?php

namespace App\Somewhere\In\Your\Application;

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

class SignalSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            ConsoleEvents::SIGNAL => 'handleSignal',
        ];
    }

    public function handleSignal(ConsoleSignalEvent $event): void
    {
        $signal = $event->getSignal();

        // #1 Some log
        $this->logger->info('Signal received'., [
            'signal' => $signal,
        ]);

        // #2 Stop the command if it implements StoppableCommandInterface
        // StoppableCommandInterface is not part of the Symfony Console component
        // It's up to you to implement it in your application
        if (
            $event->getCommand() instanceof StoppableCommandInterface)
            && in_array($signal, [SIGINT, SIGTERM], true)
        ) {
            $event->getCommand()->stop();
            $event->abortExit();
        }

        // #3 Do not stop on SIGUSR1 or SIGUSR2
        // By default, PHP will stop on SIGUSR1, or SIGUSR2, let's change that
        if (in_array($signal, [SIGINT, SIGTERM], true)) {
            $event->setExitCode(null);
        }

        // #4 Set a custom status code on other signals
        $event->setExitCode(128 + $signal);
    }
}

If you want to dispatch more events when a signal is received, you can use the Application::setSignalsToDispatchEvent() method:

// bin/console

$application = new Application($kernel);
$application->setSignalsToDispatchEvent([SIGINT, SIGTERM, SIGUSR1, SIGUSR2, SIGALRM]);

Or you can configure it in the command itself:

class MyCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->getApplication()->setSignalsToDispatchEvent([SIGINT, SIGTERM, SIGUSR1, SIGUSR2, SIGALRM]);
    }

Section intitulée conclusionConclusion

Handling signals is very important when you want to write a long-running command, or when the command handles critical data like payments.

As you can see, as of Symfony 6.3 it is very easy to handle signals with Symfony Command. We invite you to always write this kind of code to ensure your application is safe.

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise