Commands That Run Unattended
Habits that make a scheduled command safe: idempotence, a --dry-run option, logging instead of printing, and a lock so two copies cannot overlap.
$this->lock(?string $name = null, bool $blocking = false): boolA command you type is forgiving. A command that cron or a scheduler runs at 02:17 has nobody watching, so it needs four habits.
Be idempotent
Idempotent means running it twice does no more than running it once. Decide what to do from the current state, not from the assumption that the last run finished. The job will be killed mid-way at some point, and the next run has to cope.
Offer a dry run
A --dry-run option reports what would change and changes nothing. It makes the command safe to test against production data, and it is the first thing anyone asks for when the output looks wrong. Guard every write with it, including the ones that look harmless.
Log, do not print
Printing to stdout is for a person reading a terminal. Under cron that output goes to a mail spool or nowhere. Type-hint Psr\Log\LoggerInterface on a parameter of __invoke() and Symfony passes a logger in; write the run's facts there, with counts in the context array, and keep $io for the human case. Stage 2 covers where those log records end up.
Lock
If a run takes longer than the interval between runs, two copies overlap. LockableTrait adds lock() and release(). lock() returns false when another copy holds the lock, so exit quietly; passing true as its second argument waits instead. The lock is released automatically when the command ends.
Finally, return the right exit code. Monitoring reads the number, not the prose.
Example
<?php
// src/Command/ArchiveLogsCommand.php
declare(strict_types=1);
namespace App\Command;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
#[AsCommand(name: 'app:logs:archive', description: 'Moves log files older than 30 days into an archive directory.')]
class ArchiveLogsCommand
{
use LockableTrait;
public function __invoke(
SymfonyStyle $io,
LoggerInterface $logger,
#[Argument('Directory holding the log files')]
string $directory,
#[Option(description: 'Report what would move, and move nothing')]
bool $dryRun = false,
): int {
if (!$this->lock()) {
$logger->info('app:logs:archive skipped, another copy holds the lock');
return Command::SUCCESS;
}
$filesystem = new Filesystem();
$archive = $directory.'/archive';
$moved = 0;
// depth(0) keeps the scan out of the archive directory it writes into
$old = Finder::create()->files()->in($directory)->depth(0)->date('before 30 days ago');
foreach ($old as $file) {
$target = $archive.'/'.$file->getFilename();
// already archived by an earlier run: skip, so a second run is a no-op
if ($filesystem->exists($target)) {
continue;
}
if (!$dryRun) {
$filesystem->mkdir($archive);
$filesystem->rename($file->getPathname(), $target);
}
++$moved;
}
$logger->info('app:logs:archive finished', ['moved' => $moved, 'dry_run' => $dryRun]);
$io->writeln(sprintf('%d file(s) %s', $moved, $dryRun ? 'would move' : 'moved'));
return Command::SUCCESS;
}
}
// $ php bin/console app:logs:archive var/log --dry-run
// 4 file(s) would moveWhen to use it
- Turn a nightly cleanup script into a command cron can run without supervision.
- Add --dry-run to a data-fixing command so you can review the plan before it writes.
- Stop a slow import from overlapping with its own next scheduled run.
- Make a worker finish its current job when a deploy sends SIGTERM.
More examples
The crontab line that calls it
Three details earn their place. `-n` stops an unexpected prompt from hanging the job until the next run collides with it. `-q` keeps the mail quiet on a normal run while still reporting errors. And the explicit `cd` plus `APP_ENV` matter because cron's environment is almost empty: no working directory you recognise, and none of the shell variables your terminal has. The `echo $?` line is the check worth wiring into monitoring, since a command can print a reassuring summary and still have failed.
# /etc/cron.d/app-archive
# m h dom mon dow user command
17 2 * * * www-data cd /srv/app && APP_ENV=prod php bin/console app:logs:archive var/log -n -q >> /var/log/app-archive.log 2>&1
# -n never prompt; every question takes its default
# -q no normal output, but errors still reach stderr
# 2>&1 keeps those errors in the same file as everything else
#
# Try it by hand first, in this order:
# php bin/console app:logs:archive var/log --dry-run -vv
# php bin/console app:logs:archive var/log
# echo $? # 0 means success; anything else is a failure to alert onA lock that holds across machines
The default lock is a semaphore or a file on the local disk, which is enough for one server and useless the moment you run two. Setting the `$lockFactory` property, possible since Symfony 7.1, lets the lock live somewhere both machines can see. The factory is configured separately, through the Lock component, with a store backed by Redis, a database or another shared service. The comment about property promotion is a real constraint rather than a style note: the trait declares the property, so promoting the constructor parameter is a redeclaration and the class will not load.
<?php
// src/Command/ArchiveLogsCommand.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\LockableTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
#[AsCommand(name: 'app:logs:archive')]
class ArchiveLogsCommand
{
use LockableTrait;
// Do not use constructor property promotion here: LockableTrait already
// declares the $lockFactory property, and promoting it would redeclare it.
public function __construct(LockFactory $lockFactory)
{
$this->lockFactory = $lockFactory;
}
public function __invoke(SymfonyStyle $io): int
{
if (!$this->lock()) {
$io->writeln('Another copy holds the lock.');
return Command::SUCCESS;
}
// ... the work
$this->release();
return Command::SUCCESS;
}
}Stopping cleanly when the process is told to stop
A long-running command that is killed in the middle of a unit of work leaves half-finished state behind, and idempotence then has to clean it up. Handling the signal turns that into a controlled stop: the handler only sets a flag, the loop finishes the job it is on and exits. Returning `false` from `handleSignal()` is what keeps the process alive long enough to do that; returning an integer exits immediately with that code. The `SIGTERM` and `SIGINT` constants come from the PCNTL extension, which must be installed for this to work.
<?php
// src/Command/ProcessQueueCommand.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\Style\SymfonyStyle;
#[AsCommand(name: 'app:queue:process', description: 'Processes queued jobs until asked to stop.')]
class ProcessQueueCommand implements SignalableCommandInterface
{
private bool $shouldStop = false;
public function getSubscribedSignals(): array
{
// SIGTERM is what a deploy or an orchestrator sends; SIGINT is Ctrl+C
return [\SIGTERM, \SIGINT];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->shouldStop = true;
// false means: do not exit yet, let __invoke() finish its current step
return false;
}
public function __invoke(SymfonyStyle $io): int
{
while (!$this->shouldStop) {
// ... handle exactly one job, then look at the flag again
}
$io->writeln('Stopped after finishing the job in hand.');
return Command::SUCCESS;
}
}
Discussion