Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Daemon Bundle Laravel Package

miksoftware/daemon-bundle

Miksoftware DaemonBundle provides a Symfony-friendly foundation for building long-running PHP daemon and worker commands, with structured loops, lifecycle handling, and graceful shutdown support for background jobs and queue processing.

View on GitHub
Deep Wiki
Context7

Getting Started

Calibrate first: this is a Symfony bundle, not a Laravel package. There is no config/app.php provider entry, no vendor:publish tag, no facade, no ServiceProvider. It ships with 5 stars, 0 dependents, and a NOASSERTION license — the license text is not machine-recognizable, so a human has to read and clear it before the code touches a production image. Treat the source as the documentation.

Minimal steps, in order:

  1. License gate. Read LICENSE before anything else. NOASSERTION on Packagist means "we could not detect a standard license" — it does not mean "permissive." If legal says no, stop here and use queue:work/Horizon.
  2. Pin the version. No downstream users means a silent patch release is a real event: "miksoftware/daemon-bundle": "x.y.z" with an exact version, not a caret.
  3. composer require miksoftware/daemon-bundle and immediately run composer why symfony/console / composer why symfony/dependency-injection. Laravel pins Symfony components tightly; a bundle that requires symfony/console:^7 against a Laravel 10 app will either refuse to install or force a resolution you don't want.
  4. Read in this specific order (≈20 minutes, saves a day): composer.json (autoload namespace, Symfony constraints) → DependencyInjection/ (what services are registered, under which IDs, and which are public) → the base worker/command class the bundle exposes → README. You are looking for one thing: is there a plain class with a run()/start() method I can instantiate myself? If yes, you can use this from Laravel. If the worker only exists as a Symfony DI service with constructor-injected Symfony-specific dependencies, the bridge cost goes up sharply.
  5. Do not register it in config/bundles.php — that file does not exist in Laravel. Either instantiate the worker inside an Artisan command and inject dependencies by hand via app(), or (recommended for most teams) skip the bundle entirely and implement the loop yourself with SignalableCommandInterface; you'll get the same graceful-shutdown semantics with zero dependency risk.

First use case to prove it out: a single long-running Artisan command that polls one table (outbox_messages WHERE processed_at IS NULL), processes exactly one row per iteration, logs the row ID, and exits 0 on SIGTERM after completing the in-flight row. Run it in the foreground, Ctrl-C it mid-row, and verify the row was fully processed and the exit code was 0. If that doesn't hold, nothing downstream will.

// app/Console/Commands/OutboxDaemon.php
class OutboxDaemon extends Command implements SignalableCommandInterface
{
    protected $signature = 'outbox:daemon {--once} {--max-time=3600}';

    private bool $shouldStop = false;

    public function getSubscribedSignals(): array
    {
        return [SIGTERM, SIGINT];
    }

    public function handleSignal(int $signal): void
    {
        $this->shouldStop = true;   // flip a flag, never exit() here
    }

    public function handle(): int
    {
        $deadline = time() + (int) $this->option('max-time');
        $i = 0;

        while (! $this->shouldStop && time() < $deadline) {
            $done = $this->processOne();      // returns bool: did work

            if ($this->option('once')) {
                break;
            }
            if (! $done) {
                usleep(500_000);              // idle backoff; see Gotchas
            }
            if (++$i % 100 === 0) {
                $this->tune($i);
            }
        }

        $this->cleanup();
        return self::SUCCESS;
    }
}

Implementation Patterns

Bridge once, in one file. If you do use the bundle, all coupling lives in a single Artisan command that constructs the bundle's worker and passes it a closure for the iteration body. Everything else in your app talks to your own OutboxWorker class, not the bundle.

// The only place the dependency appears:
$worker = new \MikSoftware\DaemonBundle\Worker(
    logger: app(LoggerInterface::class),
);
$worker->onTick(fn () => app(OutboxWorker::class)->processOne());
$worker->run();

If the bundle has no such seam, that itself is the finding — write your own loop.

Graceful shutdown is the whole value proposition, so get it exact. The pattern that matters day to day:

  • Register SIGTERM/SIGINT handlers that flip a boolean. Never call exit() inside a handler while a DB transaction is open — you'll leave half-written rows and a lock held until wait_timeout.
  • The loop checks the flag at the top of the next iteration only; the current item always finishes.
  • After the loop: flush log buffers, DB::disconnect(), release in-flight claims, write the final heartbeat, return self::SUCCESS.
  • If you use pcntl_signal directly rather than SignalableCommandInterface: call pcntl_async_signals(true) (PHP 7.1+) or pcntl_signal_dispatch() every iteration. Miss this and you'll spend an afternoon debugging a "hang" that is actually an un-dispatched signal.

Use Symfony's signal registry rather than hand-rolled pcntl. Laravel already ships symfony/console, so implementing SignalableCommandInterface (above) gives you cross-platform handling and free integration with $this->trap()-style semantics without adding a dependency.

Idempotency is mandatory. Anything a daemon touches will eventually run twice: deploy mid-flight, supervisor SIGKILL after a stop timeout, a crash between the DB write and the ack. Use a claim pattern, not a read-then-write:

UPDATE outbox_messages
   SET locked_at = NOW(), worker_id = ?
 WHERE id = ? AND processed_at IS NULL AND locked_at IS NULL

affectedRows === 1 means you own it. Then the handler must be safe to re-run.

Hybrid queue pattern. The most practical use of a long-lived worker in a Laravel codebase isn't replacing the queue — it's feeding it. The daemon owns a resource that is expensive to establish per-job (a socket, a stream consumer, a persistent upstream connection), and dispatches normal Laravel jobs for anything heavier than a few seconds. This keeps retries, backoff, and failed-job handling in Horizon where it belongs, while the daemon stays dumb and cheap.

Observability you add yourself. Ship three things on day one:

  • A heartbeat row or cache key written every N iterations (Cache::put("daemon:outbox:heartbeat", now(), 120)), with an uptime check alerting when it goes stale. Process supervision tells you the process exists; the heartbeat tells you it's working.
  • Structured log context on every iteration: ['iter' => $i, 'row_id' => $id, 'ms' => $elapsed, 'mem_mb' => round(memory_get_usage(true)/1048576)].
  • A --max-time flag, always. Even if supervisor restarts it, a self-imposed ceiling caps leak damage and makes deploys deterministic. --once is what your integration tests will actually run.

Supervisor (or systemd) is part of the implementation, not an afterthought. The daemon does not supervise itself.

[program:outbox-daemon]
command=php /var/www/artisan outbox:daemon --max-time=3600
autostart=true
autorestart=unexpected
stopsignal=TERM
stopwaitsecs=30
stdout_logfile=/dev/stdout
redirect_stderr=true

stopsignal=TERM + stopwaitsecs > your worst-case iteration time. If stopwaitsecs is shorter than a slow iteration, supervisor escalates to SIGKILL, and your graceful shutdown was theater.

Gotchas and Tips

The license is a hard blocker, not a footnote. NOASSERTION means your scanner cannot classify it. Until a human reads the file and legal signs off, treat this as unlicensed and unsuitable for anything on the critical path. This single fact usually ends the evaluation.

Bundle registration does not apply. config/bundles.php, Extension, Configuration trees, and service tags are Symfony concepts. In Laravel you either bypass the DI extension entirely or the package doesn't work.

Constructor injection won't resolve. Bundle services are wired by Symfony's container. Inside Laravel, app()->make(SomeBundleWorker::class) only succeeds if its dependencies happen to be type-hinted to interfaces Laravel can bind. Read the services.yaml/DI extension and either bind those Symfony interfaces to Laravel equivalents in a service provider, or construct the worker manually.

pcntl is CLI-only and Unix-only. extension_loaded('pcntl') is false on Windows and usually absent on some minimal PHP builds. Guard the signal registration; degrade to flag-only shutdown rather than fataling at boot.

MySQL server has gone away will bite you within hours. wait_timeout is typically 28800s but connection-level interactive_timeout and proxies (RDS Proxy, PgBouncer) cut it far sooner. Wrap the iteration and catch QueryException / PDOException, then DB::purge() and reconnect. An idle poll loop that sleeps 30s between checks is exactly the profile that loses its connection overnight.

Memory creeps, and it's your job. Daemons leak in three places: (1) query results retained by reference (a ->get() collected "for the final summary"), (2) log context arrays with large payloads, (3) container singletons that accumulate listeners. Concretely: DB::purge() every ~500 iterations, log memory_get_usage(true) (real allocated pages, not memory_get_usage()), never build unbounded arrays, and re-register event listeners if you see the listener count grow.

Deploys need an explicit restart path. With queue workers you have php artisan queue:restart writing a cache flag. Your daemon has nothing. Implement it: check a Cache key (daemon:restart-at) at the top of the loop, and set $this->shouldStop = true when it changes. Otherwise every deploy leaves the old code running until supervisor's stopwaitsecs fires on the next crash. Never deploy by SIGKILL — you'll orphan claims.

Orphaned claims are the top production footgun. If the process dies holding locked_at rows, they're invisible forever. Add a reaper (a separate scheduled command, not the daemon) that releases claims older than N minutes where worker_id is not in the live set.

Monolog holds the log file descriptor open. With single or daily channels, a long-lived process keeps writing to an inode that logrotate has already renamed — writes vanish. Use copytruncate in logrotate, or force Log::forgetChannel() on SIGHUP, or push logs to stdout and let supervisor capture them.

usleep drift and clock jumps. Sleeping 500ms per idle iteration means the interval is 500ms plus query time. For anything schedule-sensitive, compute the next tick against a monotonic source (hrtime) rather than assuming sleep accuracy, and never use wall-clock deltas after a sleep on a VM that suspends.

Never run this inside an Octane worker. Octane keeps a process alive across requests with state you don't control. A daemon loop there blocks every request that worker would serve. Daemons are standalone CLI processes, full stop.

Testing: don't test the loop, test the tick. Extract processOne(): bool as a public method and unit-test it against a fake repository: empty queue returns false, claimed row runs the handler once, handler exception releases the claim and rethrows or logs. Then one integration test runs artisan outbox:daemon --once and asserts the row transitioned.

Extension points to look for in the source: an onTick/onShutdown/onError callback seam, a configurable interval, and a logger injected rather than instantiated. If the bundle news up its own Logger or hardcodes sleep(1), you cannot control it in production and should fork or replace it.

Have the exit strategy written down before you adopt. Wrap the worker behind an interface (OutboxRunner::run(): void) so that swapping the bundle for a hand-rolled loop, a queue:work command, or symfony/process supervision is a one-file change. With 5 stars and one maintainer, a fork is a realistic scenario — plan for it as the default, not the emergency.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings