m6web/daemon-bundle
Symfony bundle for turning console commands into long-running daemons. Extend the provided daemon command, implement your loop, and get signal handling, graceful shutdown, and simple process lifecycle management without boilerplate. Ideal for workers, queue consumers, and background jobs.
Install with Composer and register the bundle. On Symfony 4+/Flex, auto-discovery usually handles it; otherwise add new M6Web\Bundle\DaemonBundle\M6WebDaemonBundle() to config/bundles.php or your kernel. As of v9.0.0 the bundle targets Symfony 7.3+ explicitly — pin ^9.0 and confirm your framework version before upgrading from 8.x.
The first place to look is the base class itself: M6Web\Bundle\DaemonBundle\Command\DaemonCommand, plus the new ConsoleCommandListener class introduced in v9.0.0 (this is now where signal handling lives — see the gotchas). Any example commands in the README still apply. The key contract is simple: extend DaemonCommand, implement configure() and doExecute(), and do not implement execute() — the bundle owns execute() and uses it to wire up a ReactPHP event loop with a periodic timer.
A minimal command looks like this:
<?php
namespace App\Command;
use M6Web\Bundle\DaemonBundle\Command\DaemonCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class PollQueueCommand extends DaemonCommand
{
protected function configure(): void
{
$this->setName('app:poll-queue')
->setDescription('Process new queue items every second');
$this->setLoopInterval(1_000_000); // microseconds, i.e. 1 second
}
protected function doExecute(InputInterface $input, OutputInterface $output): void
{
// One tick: fetch a bounded batch, process it, return quickly.
}
}
Run it in the foreground:
php bin/console app:poll-queue --env=prod --no-debug
The first realistic use case is a polling loop: check a queue, a database table, or an API every second and process new work. Run it locally for a few ticks, watch the output, then stop it with Ctrl-C and verify the shutdown path works before putting it under a supervisor. On v9.0.0 that Ctrl-C path now goes through ConsoleCommandListener rather than the command itself, so verify shutdown actually fires after upgrading.
Use initialize() for one-time setup, doExecute() for the loop body. Symfony calls initialize() once before execute(), so it is the right place to resolve services, open connections, or validate configuration. doExecute() runs every tick and should be short, idempotent, and bounded — process a fixed batch size rather than “all available work.”
Keep the tick interval in configure(). setLoopInterval() configures the periodic timer; in the versions I have seen the unit is microseconds (default roughly one second). Choose the slowest interval that meets your latency requirement. Shorter intervals mean more wakeups, more DB/API calls, and more log volume.
Stop cleanly with stopDaemon(). Call $this->stopDaemon() from inside doExecute() when the work is finished: a --max-iterations option, a --max-memory guard, a lost lock, or a drain signal. The loop exits normally and the command returns an exit code. Pair this with the bundle's signal handling (now in ConsoleCommandListener) so systemctl stop and supervisorctl stop do not kill the process mid-write.
Handle signals through the listener, not the command, on v9+. The bundle's handleSignal() method was removed from the command in v9.0.0 and reimplemented in M6Web\Bundle\DaemonBundle\Listener\ConsoleCommandListener to avoid colliding with Symfony 7.3's own handleSignal. If you previously overrode or called $this->handleSignal() in a custom command, move that logic into a listener of your own, decorate/extend ConsoleCommandListener, or hook the console command events directly.
Use the lifecycle events for cross-cutting concerns. The bundle dispatches events such as DAEMON_PRE_EXECUTE, DAEMON_POST_EXECUTE, DAEMON_LOOP_EXCEPTION, and DAEMON_STOP. Register listeners as services to record tick duration, processed counts, heartbeat, and exception details. This is usually cleaner than subclassing further — and it is now the only supported way to observe loop and signal behaviour without touching bundle internals.
Run it under a supervisor. “Daemonized” here means the process manages its own loop; it does not fork into the background. Use systemd or Supervisor with Restart=always / autorestart=true, a non-root user, and stdout/stderr captured to a log or journal. Always run with --env=prod --no-debug.
Guard against duplicate instances. There is no built-in single-instance lock. Use flock() on a lock file, Symfony Lock, or a Redis lock acquired in initialize() and refreshed each tick. Two copies of the same polling daemon will double-process work.
Make it testable. Unit-test the service that doExecute() delegates to, not the loop. For an integration smoke test, add a --once option that performs one batch and calls stopDaemon(), or extract the tick body into a callable you can invoke directly. Avoid booting the full command just to test one iteration. Since signal handling moved out of the command, you can also test your listeners in isolation now.
Know when not to use it. For new work, compare against Symfony Messenger’s worker or a plain while loop with sleep(). This bundle is a good fit when you are on a compatible Symfony/PHP stack and want a small polling daemon with signal handling and event hooks; it is not a job queue, scheduler, or worker pool.
handleSignal() is gone from the command; signal handling is now owned by ConsoleCommandListener. Any subclass that overrode it, or any code calling it directly, will break on upgrade. Grep your codebase for handleSignal before bumping. This was done specifically because the method name collided with Symfony 7.3's new handleSignal.composer.json constraints for the exact PHP/Symfony matrix rather than assuming.execute(). That is the bundle’s loop implementation. Implement doExecute() instead. Overriding execute() either causes a fatal error (if it is final) or silently removes the daemon loop.doExecute() failure is often caught and re-dispatched as DAEMON_LOOP_EXCEPTION. That is good for resilience and bad for visibility: if no listener logs or alerts, failures are silent. Always log with context and alert on repeated exceptions.$em->clear() after each batch, avoid unbounded arrays, and log memory_get_usage(true) periodically. A --max-memory option that calls stopDaemon() and relies on the supervisor to restart is a pragmatic safety valve.EntityManager forever without clearing it.pcntl. Signal handling is unavailable on Windows and requires the pcntl extension. If SIGTERM is not handled — for example because the new ConsoleCommandListener was not registered after upgrading — supervisorctl stop waits for the kill timeout. Verify SIGINT/SIGTERM behaviour after every major bump. Keep ticks short so shutdown is responsive.configure() runs during construction. The container has not been injected yet when configure() executes, so do not call $this->getContainer() there. If the interval must be runtime-configurable, pass it via the constructor or an option rather than reading a container parameter in configure().$output->writeln() on every tick in production is I/O and log volume. Gate it behind -v/-vv or a sampled logger.getContainer(). Resolve services once in initialize() or inject them via the constructor. If the command is registered as a service (console.command tag, automatic in modern Symfony), constructor injection is available and keeps doExecute() clean.ConsoleCommandListener) and the linked issue if you rely on any signal-related behaviour, and run the deprecation log on your Symfony version before committing.How can I help you explore Laravel packages today?