Implementing a One-Time Command in Symfony

Implementing a One-Time Command in Symfony

Table of Contents

IMPORTANT

Code is working with Symfony 5+ and PHP 8.0+.

Introduction

Sometimes you need to perform a specific task only once. For example, you may want to fill some table in the database with data. Migration is not a good option because it may take a long time and can be reverted.

In this article, I will show you how to implement a One-Time command in Symfony.

What is a One-Time command?

A One-Time command is a command that is executed only once. It is used to perform a specific task that should not be executed more than once.

How to implement a One-Time command in Symfony?

The approach is simple. We will create an entity that will store records to track if the command has already been executed.

We will also create an attribute that will be used to check if the command has already been executed.

Finally, we will create an event listener that will process all commands and check if the command has the RunOnlyOnce attribute.

Step 1: Create an Entity

First, create an entity that will be used to store records to track if the command has already been executed.

 bin/console make:entity OneTimeCommand

Output:

 created: src/Entity/OneTimeCommand.php
 created: src/Repository/OneTimeCommandRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.
  • Add field name - name of the command
  • Type string
  • Length 255 is enough for the command name.
  • Not null
 New property name (press <return> to stop adding fields):
 > name

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/OneTimeCommand.php
  • Add field launchedAt - date and time when the command was executed
  • Type datetime_immutable
  • Not null
 Add another property? Enter the property name (or press <return> to stop adding fields):
 > launchedAt

 Field type (enter ? to see all types) [datetime_immutable]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/OneTimeCommand.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >

  Success!

Make name field unique and set the default value for the launchedAt field.

src/Entity/OneTimeCommand.php
<?php

namespace App\Entity;

use App\Repository\OneTimeCommandRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OneTimeCommandRepository::class)]
class OneTimeCommand
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

+   #[ORM\Column(length: 255, unique: true)]
    private ?string $name = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $launchedAt = null;

+   public function __construct()
+   {
+       $this->launchedAt = new \DateTimeImmutable();
+   }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function getLaunchedAt(): ?\DateTimeImmutable
    {
        return $this->launchedAt;
    }

    public function setLaunchedAt(\DateTimeImmutable $launchedAt): static
    {
        $this->launchedAt = $launchedAt;

        return $this;
    }
}

Now, create a migration:

bin/console make:migration

Output:

 [WARNING] You have 1 previously executed migrations in the database that are not registered migrations.


 Are you sure you wish to continue? (yes/no) [yes]:
 >

 created: migrations/Version20240722014725.php


  Success!


 Review the new migration then run it with php bin/console doctrine:migrations:migrate

As a result, you should receive a new migration file in the migrations directory.

migrations/Version20240722014725.php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20240722014725 extends AbstractMigration
{
    public function getDescription(): string
    {
        return '';
    }

    public function up(Schema $schema): void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE SEQUENCE one_time_command_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
        $this->addSql('CREATE TABLE one_time_command (id INT NOT NULL, name VARCHAR(255) NOT NULL, launched_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
        $this->addSql('CREATE UNIQUE INDEX UNIQ_734172A35E237E06 ON one_time_command (name)');
        $this->addSql('COMMENT ON COLUMN one_time_command.launched_at IS \'(DC2Type:datetime_immutable)\'');
    }

    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('DROP SEQUENCE one_time_command_id_seq CASCADE');
        $this->addSql('DROP TABLE one_time_command');
    }
}

Run the migration:

bin/console doctrine:migrations:migrate

Output:

 WARNING! You are about to execute a migration in database "app" that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]:
 >


 [WARNING] You have 1 previously executed migrations in the database that are not registered migrations.

 Are you sure you wish to continue? (yes/no) [yes]:
 >

[notice] Migrating up to DoctrineMigrations\Version20240722014725
[notice] finished in 64.8ms, used 24M memory, 1 migrations executed, 4 sql queries

 [OK] Successfully migrated to version: DoctrineMigrations\Version20240722014725

Step 2: Create an Attribute

Add an attribute to the entity that will be used to check if the command has already been executed.

src/Attributes/RunOnlyOnce.php
<?php

namespace App\Attributes;

#[\Attribute(\Attribute::TARGET_CLASS)]
class RunOnlyOnce
{

}

Step 3: Create EventListener

The event listener will process all commands and check if the command has the RunOnlyOnce attribute.

OneTimeCommandListener.php
src/EventListener/OneTimeCommandListener.php
<?php

namespace App\EventListener;

use App\Attributes\RunOnlyOnce;
use App\Entity\OneTimeCommand;
use App\Repository\OneTimeCommandRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

/**
* Class OneTimeCommandListener
* @package App\EventListener
*/
final class OneTimeCommandListener
{
    public function __construct(
        private readonly OneTimeCommandRepository $repository,
        private readonly EntityManagerInterface $entityManager,
        private readonly LoggerInterface $logger
    ) {

    }

    /**
     * Event fired before command execution. Checks if the command was already run.
     * If so, disables the command.
     *
     * @param ConsoleCommandEvent $event
     * @return void
     */
    #[AsEventListener(event: ConsoleEvents::COMMAND)]
    public function onBeforeRun(ConsoleCommandEvent $event): void
    {
        // Check if command meets the requirements to be processed by this listener
        if (!$this->checkRequirements($event->getCommand())) {
            return;
        }

        $command = $event->getCommand();

        // Check if the command was already run
        if (!empty($history = $this->repository->findOneBy(['name' => $command->getName()]))) {
            $event->disableCommand();

            $this->logger->warning('Command {command} was already run at {date}', [
                'command' => $command::class,
                'listener' => self::class,
                'date' => $history->getLaunchedAt()->format('Y-m-d H:i:s')
            ]);
        }
    }

    /**
     * Event fired after command execution. If the command was successful, saves the command to the database.
     *
     * @param ConsoleTerminateEvent $event
     * @return void
     */
    #[AsEventListener(event: ConsoleEvents::TERMINATE)]
    public function onAfterRun(ConsoleTerminateEvent $event): void
    {
        // Check if command meets the requirements to be processed by this listener
        if (!$this->checkRequirements($event->getCommand())) {
            return;
        }

        $command = $event->getCommand();

        if ($event->getExitCode() !== Command::SUCCESS) {
            return;
        }

        $entity = (new OneTimeCommand())->setName($command->getName());

        $this->entityManager->persist($entity);
        $this->entityManager->flush();
    }

    /**
     * Check if command has RunOnlyOnce attribute. If so, the command should be run only once.
     *
     * @param Command $command
     * @return bool
     */
    private function checkRequirements(Command $command): bool
    {
        return !empty((new \ReflectionClass(get_class($command)))->getAttributes(RunOnlyOnce::class));
    }
}

How to use it?

Run the following command to create a new console command or use an existing one:

bin/console make:command app:one-time-example

Add the RunOnlyOnce attribute to the console command:

src/Command/OneTimeExampleCommand.php

<?php

namespace App\Command\OneTime;

use App\Attributes\RunOnlyOnce;
use App\Entity\Dictionary;
use App\Enum\DictionaryType;
use App\Repository\DictionaryRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:one-time-example',
    description: 'Example of one time command',
)]
+#[RunOnlyOnce]
class OneTimeExampleCommand extends Command {

    public function __construct()
    {
        parent::__construct();
    }

    protected function configure(): void
    {

    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        return Command::SUCCESS;
    }
}

Let's run the command for the first time:

bin/console app:one-time-example

The first time, the command will execute properly (if there are no issues in the code).

If you try to run the command again, you will get the following message:

02:15:34 WARNING   [app] Command App\Command\OneTimeExampleCommand was already run at 2024-07-22 02:13:48
["command" => "App\Command\OneTimeExampleCommand","listener" => "App\EventListener\OneTimeCommandListener","date" => "2024-07-22 02:13:48"]

Source code (Github)

You can find the source code on Github Gist

That's it. If you have any questions, feel free to contact me on Twitter (link in the footer).