Commands That Run Unattended
A command that cron runs must be idempotent, lock against a second copy, log instead of printing, and honour --dry-run.
public function lock(string $name, int $timeout = -1): boolA command you type is watched by a person who reads the output and reruns it if it fails. A command on a schedule has neither. Four habits make the difference.
Idempotent
Running it twice must give the same result as running it once. Select the work that is still outstanding rather than assuming the previous run finished, and make each step safe to repeat. This is what lets an operator rerun a failed job without first working out how far it got.
Locking
Inject Magento\Framework\Lock\LockManagerInterface. It has three methods: lock(string $name, int $timeout = -1): bool, unlock(string $name): bool and isLocked(string $name): bool. Take the lock first, release it in a finally block, and exit quietly when the lock is held. The default implementation in 2.4.9 picks a backend from the lock section of app/etc/env.php, defaulting to MySQL.
Logging, not printing
Under cron, $output goes to stdout, which is mailed somewhere nobody reads or discarded. Inject Psr\Log\LoggerInterface as well and log every decision; that lands in var/log/system.log and survives. Keep the writes to $output for the person running it by hand.
A dry run
Add --dry-run as an InputOption::VALUE_NONE flag. It should do all the reading and reporting and none of the writing, so a change can be reviewed before it happens.
Shorthand and longhand
$output->writeln($message) is $output->write($message, true) — the same method with the newline argument set. write() with no second argument prints without a newline.
Example
<?php
// app/code/Soundscode/Ops/Console/Command/PruneCommand.php
declare(strict_types=1);
namespace Soundscode\Ops\Console\Command;
use Magento\Framework\Console\Cli;
use Magento\Framework\Lock\LockManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'soundscode:ops:prune', description: 'Removes expired rows; safe to rerun')]
class PruneCommand extends Command
{
private const LOCK_NAME = 'soundscode_ops_prune';
public function __construct(
private readonly LockManagerInterface $lockManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report, change nothing');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$this->lockManager->lock(self::LOCK_NAME, 0)) {
$this->logger->warning('prune: skipped, another copy holds the lock');
$output->writeln('<comment>Already running</comment>');
return Cli::RETURN_SUCCESS;
}
try {
$expired = $this->countExpired();
if (!$input->getOption('dry-run')) {
$this->deleteExpired();
}
$this->logger->info(sprintf('prune: %d expired rows', $expired));
$output->writeln(sprintf('<info>%d expired rows</info>', $expired));
} finally {
$this->lockManager->unlock(self::LOCK_NAME);
}
return Cli::RETURN_SUCCESS;
}
}
// $ bin/magento soundscode:ops:prune --dry-run
// 42 expired rows
//
// $ bin/magento soundscode:ops:prune # while the first copy is running
// Already runningWhen to use it
- Run a nightly cleanup from crontab without two copies overlapping after a slow night.
- Give an operator a --dry-run so a bulk price change can be reviewed before it is applied.
- Diagnose a scheduled job that appeared to do nothing, using its own log file rather than lost stdout.
More examples
Quiet by default, chatty when asked
The split matters because the same class serves two callers. A person wants a line per step; cron wants silence unless something went wrong, and wants the record in a file it can grep next week. Checking getVerbosity() lets an operator pass -q for a silent run or -v for detail without you adding flags of your own. Note the exit code is the one signal cron genuinely reads: a wrapper script should branch on $? rather than parse the text.
<?php
// Inside execute(), after the lock is taken.
declare(strict_types=1);
// Always goes to var/log/system.log, whoever started the command.
$this->logger->info(sprintf('prune: %d expired rows', $expired));
// Only reaches a terminal. -q suppresses it; cron discards it.
if ($output->getVerbosity() >= OutputInterface::VERBOSITY_NORMAL) {
$output->writeln(sprintf('<info>%d expired rows</info>', $expired));
}
// writeln() is write() with the newline flag set. These two lines are identical.
$output->writeln('<info>done</info>');
$output->write('<info>done</info>', true);
// write() without the flag prints no newline, which is how a bar stays on one line.
$output->write('.');A dedicated log file through a virtual type
Scheduled jobs produce steady volume, and mixing it into system.log makes both harder to read. Two virtual types fix that without a new class: one configures Magento\Framework\Logger\Handler\Base with a file name, the other builds a Monolog instance that uses it. The third block says that this command's $logger parameter gets that logger, while every other class keeps the default. The fileName path is relative to the Magento root, so the example writes to var/log/soundscode_ops.log. Run bin/magento cache:flush and bin/magento setup:di:compile after saving, then check the file appears on the next run.
<?xml version="1.0"?>
<!-- app/code/Soundscode/Ops/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<virtualType name="SoundscodeOpsLogHandler" type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/soundscode_ops.log</argument>
</arguments>
</virtualType>
<virtualType name="SoundscodeOpsLogger" type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">soundscodeOps</argument>
<argument name="handlers" xsi:type="array">
<item name="system" xsi:type="object">SoundscodeOpsLogHandler</item>
</argument>
</arguments>
</virtualType>
<type name="Soundscode\Ops\Console\Command\PruneCommand">
<arguments>
<argument name="logger" xsi:type="object">SoundscodeOpsLogger</argument>
</arguments>
</type>
</config>
<virtualType name="SoundscodeOpsLogHandler" type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/soundscode_ops.log</argument>
</arguments>
</virtualType>
<virtualType name="SoundscodeOpsLogger" type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">soundscodeOps</argument>
<argument name="handlers" xsi:type="array">
<item name="system" xsi:type="object">SoundscodeOpsLogHandler</item>
</argument>
</arguments>
</virtualType>
<type name="Soundscode\Ops\Console\Command\PruneCommand">
<arguments>
<argument name="logger" xsi:type="object">SoundscodeOpsLogger</argument>
</arguments>
</type>
</config>
Discussion