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.
Architecture fit — the central finding. This package is a Symfony Console library, not a Laravel package. It ships no service provider, no config file, and no Laravel-facing API. Its value is a DaemonizableCommand base class that wraps execute() in an endless while loop and uses pcntl_signal to break out on SIGTERM/SIGINT.
Laravel's Artisan commands extend Illuminate\Console\Command, which is itself a Symfony Command subclass. DaemonizableCommand is a sibling, not an ancestor. You cannot extend both, so adoption forces a choice: keep Laravel's command ergonomics ($this->info(), option(), argument(), confirm(), table(), progressBar(), handle() convention, $this->laravel, call()) or get the loop. That is a real DX tax for a fairly small amount of code.
Integration feasibility. Composer install is trivial and version constraints (PHP 8.x, Symfony Console 6/7) are fine. The gate is the pcntl extension:
docker-php-ext-install pcntl and a rebuild of the build pipeline.Mechanism risk. The package relies on declare(ticks=1) for signal dispatch, which is a known footgun — the declaration must be in the entrypoint script that runs the loop, not just the class. Ticks-based dispatch also carries a measurable per-statement overhead and is superseded in practice by pcntl_async_signals(true). Separately, Laravel 10.x+ already exposes Command::trap() / untrap() via a signals trait — verify against your target version, but if it holds, the package's headline feature is available in-framework in roughly ten lines.
Technical risk.
docker stop past the grace window, Supervisor stopwaitsecs expiry) is uncatchable. Graceful shutdown is never guaranteed.queue:work gives you --max-time, --max-jobs, --memory; this gives you nothing. Long-running PHP means RSS drift, MySQL wait_timeout disconnects, Redis idle drops, and stale Eloquent/container state.queue:work, schedule:work, Horizon, and Octane already occupy the daemon niche.Key questions to resolve before committing.
queue:work is strictly better.pcntl, and who pays for the image rebuild?Stack fit. Language and framework versions are not the problem — PHP 8.x and Symfony Console 6/7 are fine under Laravel 10–12. The friction is entirely the base-class fork and the pcntl requirement.
Migration path — evaluate in this order, escalate only on evidence.
schedule:run + cron, or schedule:work under Supervisor, drive frequency. Zero new dependencies; covers the large majority of "we think we need a daemon" requests.queue:work --max-time=3600 --max-jobs=1000 --memory=256 under Supervisor. You get free recycling, graceful shutdown, retry semantics, failed_jobs visibility, and Horizon if the team already runs it.Illuminate\Console\Command + while + pcntl_async_signals(true) + $this->trap([SIGTERM, SIGINT], ...). Roughly 30–50 lines, keeps every Laravel helper, no third-party base class.declare(ticks=1) to artisan, add pcntl to all images, and own the Symfony/Laravel version coupling on every major upgrade.Sequencing (4–6 weeks to a decision gate).
stopwaitsecs strictly inside the container grace period.queue:work path. Decide on operational evidence — restart behavior, memory profile, incident count — not on API elegance.Maintenance. The public surface is small — loop control, interval, timeout, break-on-signal — which means low churn but also a thin abstraction you will end up reading the source of. Pin the version; treat upgrades as a review event given single-maintainer concentration. The larger recurring cost is the base-class fork: every Laravel major upgrade requires re-validating how much of Illuminate\Console\Command you have given up.
Support. No provider, no config, no docs site. Knowledge lives in the README and the source. Your platform team — not the vendor — owns signal semantics, Supervisor tuning, and deploy-restart behavior. That is a permanent ownership transfer for a small feature.
Scaling. Vertical only, per process. You scale by running N supervised processes, which immediately reintroduces coordination problems the package does not address: work partitioning, duplicate claims, idempotency, backpressure. No locking primitives. Any workload that needs horizontal scale belongs on a queue; this is the wrong layer for it.
Failure modes to design for.
| Failure | Symptom | Mitigation |
|---|---|---|
| SIGKILL / OOM / grace expiry | Partial work, skipped cleanup hook | Idempotent jobs; bounded units of work |
| Memory growth | OOM restarts read as random crashes | External recycle timer or --max-time equivalent |
MySQL wait_timeout / Redis idle drop |
Uncaught exception kills the loop | Reconnect-on-exception wrapper; Supervisor backoff |
| No max runtime | Old code runs indefinitely after deploy | Explicit restart-on-deploy in the release pipeline |
| Two instances, non-idempotent job | Duplicate side effects | Distributed lock; single-instance guarantee via Supervisor |
| Thin observability | Log flooding, no iteration health | Emit heartbeat + duration metrics; alert on loop silence |
Ramp-up. Half a day to write the first command. Two to three weeks of operational tuning — grace periods, restart backoff, memory ceilings, deploy hooks, alerting on silent loops — before it is production-trustworthy. Cheaper to start than a queue, more expensive to operate than cron.
Recommendation. Default to Laravel-native primitives. Approve this package only for a genuinely long-lived, single-instance, stateful process that cron and queue:work cannot express — and only with a named owner for the pcntl/Supervisor surface. The 0.38 opportunity score is the correct read: the gap this fills has largely been closed by the framework itself.
How can I help you explore Laravel packages today?