Run the Laravel scheduler on a resident dispatcher and a warm worker pool. No cold framework boot per task.
Experimental / early prototype. The API is unstable and may change without notice before a 1.0 release. See docs/design.md for the full design rationale, architecture, and open questions — this README is the short version.
In a stock Laravel deployment, a cron sidecar execs php artisan schedule:run once a minute, and every Schedule::command() entry due at that minute spawns another child process. Each of those is a full framework cold boot — autoloading, service-provider registration, command resolution — typically hundreds of milliseconds, even with Laravel's optimize caches, before the task does any real work. Run this across a few dozen scheduled tasks, or across many services doing the same thing side by side, and steady-state CPU ends up dominated by process boots rather than by the work the tasks actually do.
A secondary problem: schedule:run discards a task's stdout/stderr unless the schedule entry explicitly opts in with sendOutputTo or a sibling hook. In practice that means scheduled-task output either disappears, or services route around it by writing to their own log files instead of whatever container-stdout-based log pipeline the rest of the application already relies on.
one container
├── octane:frankenphp warm worker pool (task server)
│ workers ×N, sandbox per run, --max-requests recycling, no ingress
│ only registers a task route when RESIDENT_SCHEDULER_SERVER=true
│ each task's live output → this worker's own STDERR
└── resident-scheduler:run resident dispatcher (main process)
1s tick → due events → TaskExecutor → hooks (before/after/onSuccess/…)
Both processes live in the same container. The dispatcher boots the framework once and loops on a 1-second tick, reusing Laravel's own isDue()/filtersPass() and mutex logic to decide what's due — only the execution path changes. Instead of spawning a process per task, it hands each due event to a TaskExecutor, an interface with one implementation today: OctaneHttpExecutor, a loopback HTTP call into the same container's octane:frankenphp worker pool. That's the same model FrankenPHP/Octane already use for HTTP requests — pre-booted workers, a fresh sandbox per run — applied to schedules instead. Because the worker that actually runs the task shares the container's log stream, a task's live output goes straight to that worker's own STDERR — no relaying, no output-only-in-a-file.
The consuming application needs PHP ^8.3, Laravel 11 or 12, and laravel/octane already running the app on the FrankenPHP driver — this package doesn't install or manage Octane itself, it only talks to the worker pool it already provides.
composer require cego/resident-scheduler-laravel
php artisan vendor:publish --tag=resident-scheduler-configThree environment variables control it:
| Variable | Default | Purpose |
|---|---|---|
RESIDENT_SCHEDULER_SERVER |
false |
Registers the task-execution HTTP endpoint on this process. Only enable it on the resident-scheduler container — never on a process with public ingress. |
RESIDENT_SCHEDULER_TOKEN |
— | Shared secret between the dispatcher and the pool. Both read it from the same container's environment. |
RESIDENT_SCHEDULER_ENDPOINT |
http://127.0.0.1 |
Where the dispatcher reaches the pool. |
Both the dispatcher and the worker pool run in the same container, as two processes. A generic entrypoint:
#!/bin/sh
set -e
# Warm worker pool — the task route only exists because RESIDENT_SCHEDULER_SERVER=true.
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500 &
# Dispatcher — the container's main process; the container exits if this does.
exec php artisan resident-scheduler:run# docker-compose.yml
services:
scheduler:
image: your-app:latest
entrypoint: ["/entrypoint.sh"]
environment:
RESIDENT_SCHEDULER_SERVER: "true"
RESIDENT_SCHEDULER_TOKEN: "${RESIDENT_SCHEDULER_TOKEN}"
RESIDENT_SCHEDULER_ENDPOINT: "http://127.0.0.1"No changes to any existing Schedule:: definitions are required.
| Feature | Status | Notes |
|---|---|---|
->cron() and frequency helpers (daily(), hourly(), ...) |
✅ | DueEvaluator calls Laravel's own Event::isDue() unmodified. |
Sub-minute repeats (everyFiveSeconds(), etc.) |
✅ | Own catch-up/modulo logic; tested for cold start mid-minute, delayed-tick catch-up, and steady-state repeat firing. |
withoutOverlapping() (+ custom expiry) |
✅ | Reuses the event's own EventMutex binding; tested. |
onOneServer() |
✅ | Via Schedule::serverShouldRun(), unmodified; tested. |
runInBackground() |
✅ | Dispatched without waiting, driven forward by TaskExecutor::progress(); concurrent dispatch (not real OS backgrounding, since execution happens on the worker pool) is tested. |
environments() / evenInMaintenanceMode() / timezone() |
✅ | All three ride Laravel's own Event::isDue(), evaluated dispatcher-side; the call site is tested, the individual filters aren't each covered by a dedicated test. |
when() / skip() / between() / unlessBetween() |
✅ | All four ride Laravel's own Event::filtersPass(), evaluated dispatcher-side (between/unlessBetween are themselves built on when/skip by Laravel); when() is tested directly. |
user() (exec events) |
exec() events run via Symfony\Process::fromShellCommandline($event->command) directly, not $event->buildCommand() — the sudo -u wrapper stock Laravel adds there is never applied. |
|
before() / after() |
✅ | Tested. |
onSuccess() / onFailure() |
✅ | Tested for both branches of the exit code. |
pingBefore() / thenPing() (+ ...If, pingOnSuccess, pingOnFailure) |
✅ | Built by Laravel on top of before()/then()/onSuccess()/onFailure(), which are tested directly; the ping wrappers themselves aren't separately tested. |
sendOutputTo() / appendOutputTo() |
✅ | Tested. |
emailOutputTo() / emailWrittenOutputTo() / emailOutputOnFailure() |
✅ | The output file is written before after-callbacks run, so Laravel's own file-read-then-mail logic sees it; not covered by a dedicated test. |
Schedule::command() / ->call() / ->exec() |
✅ | Each runs through a distinct path in the task runner; all three are tested. |
Schedule::job() |
✅ | Laravel itself reduces job() to a CallbackEvent closure — the same path as ->call(); not covered by a dedicated test. |
Named events (->name()) |
✅ | Plain Laravel state; exercised throughout the test suite via mutex/cache keys. |
schedule:list |
Still the stock command; this package doesn't register a replacement or interact with it, and it isn't exercised by this package's test suite. | |
schedule:test parity |
Planned | No resident-scheduler:test exists yet. Stock schedule:test execs the task directly, bypassing the executor and worker pool entirely. |
Adding/removing/rescheduling a Schedule:: entry at runtime |
v0.1 limitation | The dispatcher materializes its schedule once at boot; changing which entries exist requires restarting the dispatcher process. when()/skip() filters are unaffected — they still evaluate live, every tick. See docs/design.md for why. |
exit()inside a scheduled command kills the pool worker that ran it, not "its own process". The pool auto-respawns the worker, but the task is reported as failed instead of exiting with whatever code it chose. Worth auditing scheduled-task code forexit()calls before adopting this package.memory_limitis per worker, not per task process — usage accumulates across every task that happens to land on a given worker, until--max-requestsrecycling restarts it.- Tasks now run inside the same sandbox isolation that already applies to ordinary Octane/FrankenPHP HTTP requests: state doesn't leak between runs by design, but scheduled-task code has never previously run under that guarantee. A static property mutated during one task's run persists until the worker recycles, and is visible to whatever task lands on that same worker next.
- Task output now lands live on the container's own stdout/stderr instead of being discarded or written to a file — that's the point, but it does mean the volume that used to go nowhere now flows into whatever log pipeline already reads the container's stdout/stderr.
- A foreground task (anything not marked
runInBackground()) blocks the dispatcher's tick loop until it finishes, so no other due event fires while it runs — stock cron doesn't have this limitation, since every due entry there gets its own OS process. Mark long-running tasksrunInBackground()to avoid blocking the loop. If the loop is blocked long enough to skip a minute boundary entirely, only the latest missed minute is caught up, not every one that was skipped.
The HTTP round-trip in OctaneHttpExecutor exists because FrankenPHP doesn't yet have native, non-HTTP background/task workers. Once that support lands, a FrankenPhpTaskExecutor implementing the same TaskExecutor contract against the native API — swapping the executor binding and deleting the HTTP route, the shared-secret header, and the request/response envelope — should be the entire migration. The dispatcher, EventRefs, hooks, and output handling don't change. Tracking:
- php/frankenphp#2287 — non-HTTP background workers with shared state
- php/frankenphp#2393 — worker config and an
ensuremechanism - php/frankenphp#2319 — a task API for bidirectional background-worker communication
A ForkExecutor (pcntl fork-per-task) is a plausible cheaper alternative to the HTTP pool for deployments that don't need per-run sandbox isolation — it isn't built yet. If it is, the natural next step is a throughput/latency benchmark against OctaneHttpExecutor to quantify what that isolation actually costs.
MIT. See LICENSE.