wrep/daemonizable-command
Laravel package for turning Artisan commands into long-running daemons. It provides a base command with loop control, graceful shutdown, signal handling, and start/stop/restart support, making it easier to build reliable background workers and scheduled tasks.
Install and verify the environment
composer require wrep/daemonizable-command
php -m | grep -i pcntl # must be present
php -r 'var_dump(function_exists("pcntl_alarm"));'
The package is plain classes, not a bundle — nothing to register in config/bundles.php on current versions. It requires ext-pcntl and therefore a POSIX host; Windows is out. If pcntl_signal/pcntl_alarm show up in disable_functions (common on shared hosting), the command will fail at runtime rather than at install time, so check before you build on it.
Smallest working command
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Wrep\Daemonizable\Command\DaemonizableCommand;
class PingCommand extends DaemonizableCommand
{
protected function configure(): void
{
parent::configure(); // mandatory: adds --run-once / --ignore-errors and signal wiring
$this->setName('app:ping')->setDescription('Ticks forever');
$this->setLoopInterval(5); // seconds between iterations
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('tick ' . date(DATE_ATOM));
return 0; // body of ONE iteration; the base class owns the loop
}
}
Register it like any console command (#[AsCommand], console.command tag, or autoconfigure: true). You should get --run-once, --ignore-errors, and a command that never returns.
First use case
Start with --run-once: it turns the daemon into an ordinary command, so you can debug it, run it in CI, and confirm DI/output/exit codes before you ever leave it looping. Then:
timeout --signal=TERM 15 bin/console app:ping -v
You should see roughly three ticks (15s ÷ 5s) and a clean exit after the iteration in flight — not a kill. If the process dies instantly, or --run-once is "unknown option", the usual cause is a missing parent::configure().
Where to look first
vendor/wrep/daemonizable-command/src/.../DaemonizableCommand.php — it is small; read the loop, the option definitions, and the signal handlers once. Everything surprising about this package is in that file.Tests/ fixtures — the canonical examples of loop interval, stopping, and timeout behaviour.bin/console app:ping --help — confirms which options actually exist in the version you installed (the surface has shifted across releases; don't trust blog posts).Setup in initialize(), work in execute()
initialize() (and interact()) run once; execute() runs every iteration. Put service resolution, lock acquisition, and "starting" logs in initialize(); put the unit of work in execute(). Do not call parent::execute().
Poll loop with a fixed interval
protected function getLoopInterval(): float
{
return 2.5; // seconds (float is fine on modern versions)
}
This is sleep after work, not "run every 5 seconds". For a cron-like cadence (every minute, on the minute) compute your own delay and return that, or handle scheduling outside the package.
Worker that drains a queue, then idles
protected function execute(InputInterface $input, OutputInterface $output): int
{
while (($job = $this->queue->pop()) !== null) {
$this->handler->handle($job);
if (!$this->isRunning()) { // cooperative shutdown between jobs
$output->writeln('shutdown requested, stopping after current job');
return 0;
}
}
return 0; // nothing left; base class sleeps, then loops again
}
Graceful shutdown
The base class traps SIGTERM/SIGINT and flips its running flag; your job is to (a) not block for minutes, and (b) check isRunning() at natural boundaries inside long batches. Override isRunning() for custom stop conditions (e.g. a "drain" flag in Redis, or a wall-clock deadline), and call stopRunning() from inside execute() when the work is genuinely finished.
Errors
By default, an uncaught exception ends the process — which is what Supervisor wants for a broken dependency. If a single bad message must not kill the daemon, catch that exception narrowly and log it with context; do not reach for --ignore-errors as a blanket policy, because it also hides programming errors and will silently exit 0.
Recycling the process
Long-lived PHP processes leak (Doctrine identity map, Monolog buffers, accumulated references). Let the daemon exit after a bounded lifetime and have the supervisor restart it: either set a timeout on the command so it stops itself, or count iterations and stopRunning() after N. Supervisor autorestart=true closes the loop.
Supervisor / systemd / Docker
[program:app-ping]
command=/app/bin/console app:ping --no-interaction
autorestart=true
startsecs=5
stopwaitsecs=60 ; must exceed your worst-case iteration
stopasgroup=true
killasgroup=true
In Docker use the exec form (CMD ["bin/console","app:ping"]) so PID 1 receives the signal, or use an init (--init) — otherwise docker stop hits the shell and you get a SIGKILL after the grace period.
Guarding against duplicates
One daemon per queue is usually a requirement. Acquire a Symfony\Component\Lock in initialize() and bail (or stopRunning()) if it is already held; two copies of a poller is the most common production incident with this class of command.
Testing
CommandTester plus --run-once gives you a normal, terminating functional test. For the loop itself, assert that a SIGTERM sent mid-run (spawn the process, posix_kill) leads to a clean exit code and no half-processed unit of work.
parent::configure() is not optional. Without it you lose --run-once/--ignore-errors and, on some versions, the signal handler installation. The symptom is The "--run-once" option does not exist.sleep(60), a slow MySQL query, or a blocking HTTP call delays shutdown by that long. Keep per-iteration work short, set statement/HTTP timeouts, and check isRunning() between chunks. If pcntl_async_signals is not enabled by your version, dispatch is tied to the loop.sleep() inside execute(). It stacks on top of the loop interval and makes your effective cadence unreadable. Use setLoopInterval().EntityManager closes on a dropped connection (MySQL wait_timeout, network blip) and every later flush() throws. Wrap work in try/catch, check $em->isOpen() (DBAL 3 removed ping()), reset the manager, and call $em->clear() every iteration or every N iterations to stop the identity map growing.gc_collect_cycles() periodically, avoid appending to properties across iterations, and watch memory_get_peak_usage() in a -vvv log line. If RSS climbs monotonically, you have a leak, not a tuning problem.ConsoleEvents::COMMAND/TERMINATE, profiler listeners, and ConsoleHandler-style Monolog bridges see one command execution. Don't build per-iteration instrumentation on them — instrument inside execute().ConsoleOutput writes to a stream (fine), but buffered handlers (fingers_crossed, in-memory test handlers) grow without bound in a process that never ends. Log to stderr/rotating files, and guard expensive $output->writeln() calls behind OutputInterface::VERBOSITY_* checks.--run-once --ignore-errors is a lie in CI. A failing iteration will be swallowed and the exit code will look clean. Use --run-once alone in pipelines.--run-once and for CommandTester assertions.composer.json — method names around timeouts/loop interval differ across majors, and the "last release 2025-06-26" tag means it is maintained, but old tutorials describe APIs that no longer exist. Confirm against --help and the vendored source.ps -o pid,stat,etime,cmd -p <pid> to spot S vs D state, strace -p <pid> -f -e trace=network,read,write for what it is blocked on, and always send SIGTERM (never SIGKILL) first so you can see whether graceful shutdown works. A daemon that only dies on -9 will corrupt in-flight work under Supervisor.getLoopInterval()/setLoopInterval(), isRunning()/stopRunning(), initialize() for one-time setup, and the timeout mechanism (if your version exposes it) for self-recycling. Subclass rather than copy-paste; the base class is the only place the loop, signals, and option parsing are wired together.How can I help you explore Laravel packages today?