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.
<?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.
<?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.
<?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
<?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:
<?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).