Skip to main content

Documentation

No results found.
Features

Lazy Cron

Most shared hosts let you configure exactly one cron entry per site, and plenty of small installs ship without a real cron at all. Laravel's scheduler expects * * * * * php artisan schedule:run to be wired in by hand — without it, every $sc...

Most shared hosts let you configure exactly one cron entry per site, and plenty of small installs ship without a real cron at all. Laravel's scheduler expects * * * * * php artisan schedule:run to be wired in by hand — without it, every $schedule->command(...) registration is dead code. WebProCMS ships a "lazy cron" so scheduled jobs run automatically on installs that have never touched cron.


The model

Lazy cron has its own database-backed registry (scheduled_tasks table) and runs jobs based on last-run / next-run timestamps instead of cron-minute matching. A task fires whenever now ≥ next_run_at — there's no "must hit the 03:15 minute" window. Crawler traffic on any public page is enough to keep daily jobs alive.

Concept Where it lives
Per-task row (interval, last_run_at, next_run_at, enabled, source) scheduled_tasks table
Registration LazyCron::register($command, $interval, $minHour, $source) — called in service provider boot()
Trigger (public-page) LazyCronTrigger middleware, web group
Runner LazyCron::run() — Cache::lock-protected, time-budgeted
CLI parity php artisan lazy-cron:run (LazyCronRun)
Admin UI Dashboard → Settings → Advanced → Lazy Cron

Intervals and min_hour

Two fields on each row decide when a task is next eligible:

  • interval_seconds — the spacing between runs. next_run_at is recomputed as now + interval_seconds at the moment the task finishes, not on a fixed wall-clock grid. A task that runs late simply schedules its next run later; it never fires several times in a row to "catch up" a backlog of missed intervals. Overdue means run once, then reschedule from now — not run N times. (This is why crawler traffic keeps a daily job alive without ever stampeding it.)

  • min_hour (nullable, 0–23) — an hour-of-day floor. A task with min_hour = 8 is skipped until the hour is ≥ 8 even once next_run_at has passed; it stays due and fires on the first opportunity after that hour. Combined with a 24 h interval this yields "once a day, but not before 08:00" — used for the error digest so it never sends at 3 a.m.

    The floor is evaluated in the site's timezone (Settings → Business Info → Site timezone, asked for during install, via DateValue::siteTimezone(), falling back to the app timezone when unset), and enforced in PHP rather than SQL so there's no cross-database timezone math. min_hour = 8 therefore means "after 08:00 local time" — the intent the number plainly expresses. It was previously read as UTC, which made 8 fire at 3am on a US-Central install: the exact outcome the floor exists to prevent. A dueNow task that fails the min_hour gate is simply skipped for that pass and re-evaluated on the next one.

    Because next_run_at is recomputed from completion, a min_hour floor also pins a daily task: the first pass after the floor runs it, the next run is anchored 24 h later at that same local time, and it stays there. That is the only way to get a predictable time-of-day out of an interval-based scheduler.

A freshly registered task has next_run_at = NULL, which counts as due — so a new task (or a whole new install) fires on its first qualifying opportunity, then settles into its interval.

How this compares to WordPress and plain system cron

Two scheduling approaches dominate PHP CMSes, and lazy cron is deliberately built to avoid the weakness in each.

vs. WordPress wp-cron: no loopback request to fail

WordPress's default wp-cron checks for due tasks during request bootstrap and, when something is due, fires a loopback HTTP request back to the server (wp-cron.php) to run the work. That self-request is the source of most "wp-cron is unreliable" reports: it silently fails behind firewalls, load balancers, basic-auth walls, or when the server can't resolve its own hostname — and in some configurations the work then runs inline in the visitor's request and slows their page.

Lazy cron has no loopback request. The runner executes in the same PHP-FPM worker, in a defer() closure that fires only after fastcgi_finish_request() has already shipped the full response to the browser (see How a public-page hit triggers cron work below). There is nothing to misconfigure and nothing to fail — and the visitor never waits on the work.

vs. a plain crontab: missed runs are picked up, not skipped

A traditional crontab fires a task at a fixed wall-clock minute. If the server is powered off, rebooting, or overloaded at that exact minute, that run is simply skipped — vanilla cron keeps no memory of a missed window.

Lazy cron is due-based, not instant-based: every task carries a next_run_at and fires whenever now ≥ next_run_at. If the server was down or too busy when a task came due, the task stays overdue and runs at the next opportunity — the first qualifying request (in auto mode) or the next cron tick (in cron mode). Missed work is caught up, not lost, and the distributed Cache::lock (see Multi-server safety) guarantees an overdue task runs once, not once per server or once per concurrent visitor.

Hooking up a system cron: the best of both worlds

Pointing a one-line system cron at lazy cron combines the reliability of a real scheduler with the resilience of the due-based model:

* * * * * php artisan lazy-cron:run
  • Runs on a dependable heartbeat regardless of whether anyone is visiting — no traffic dependency.
  • Still catches up after downtime. Unlike a plain crontab that runs your task directly (and loses the run if the box was off at that minute), this entry only nudges lazy cron, which then fires whatever is overdue. A server that was down recovers by running its missed tasks on the next tick.
  • Still no loopback request to fail, and the distributed lock still guarantees once-only execution across a fleet.
  • Still won't stampede or hog a worker — the same lock and time budget apply.

In short: the system cron provides the dependable heartbeat, and lazy cron provides the smart, self-healing dispatch. wp-cron gives you neither half cleanly, and a bare crontab gives you only the heartbeat. This is the recommended production setup; see Execution paths for the full mode matrix.

On a managed host (RunCloud, Ploi, cPanel, …)

Panel-managed servers add cron jobs through their UI, not by hand-editing a crontab. Three things trip people up:

  • Run it as the web app's system user, never root. The job must run as the same user that owns the site (e.g. the RunCloud web-app user) so file permissions, .env, and DB access match a normal request. A cron running as root writes cache/log files the web user then can't read.
  • The panel writes to /etc/cron.d/<name>, not the user's crontab — so crontab -l for the web user shows nothing even though the job exists and runs. Confirm with sudo cat /etc/cron.d/<name> and sudo grep CRON /var/log/syslog, not crontab -l.
  • Point it at the app's PHP version. Use the PHP CLI the site runs on (on a LiteSpeed/RunCloud box that's often /usr/local/lsws/lsphpXX/bin/php, not /usr/bin/php).

The "Vendor Binary" gotcha (RunCloud & friends). These panels compose the job as <Vendor Binary> <Command> — the vendor binary is the interpreter, the command is what's fed to it. Supply exactly one PHP invocation, not two:

Vendor Binary Command Runs as ✔ / ✗
PHP 8.x /home/<user>/webapps/<app>/artisan lazy-cron:run php artisan lazy-cron:run ✔ correct
/bin/bash /usr/bin/php …/artisan lazy-cron:run bash /usr/bin/php … → bash runs the PHP binary as a shell script (cannot execute binary file) ✗
PHP 8.x /usr/bin/php …/artisan lazy-cron:run php /usr/bin/php … → PHP tries to parse the PHP binary as a script (syntax error … in …/php) ✗

The likelier mistake is a copied path. Panels have no "duplicate this job" that rewrites paths, so a second site's job is usually the first one's, hand-edited — and the app folder gets changed while the user's home directory does not:

/usr/local/lsws/lsphp84/bin/php /home/siteone/webapps/sitetwo_laravel/artisan lazy-cron:run
                                      ^^^^^^^ still site one's home

Run As is right, the binary is right, the app name is right, and PHP exits with Could not open input file: …. Each install lives under its own user's home (/home/<user>/webapps/<app>), so both segments change together. Verify by running the exact composed command as that user before trusting the panel's green badge:

sudo -n -u <user> <php-cli> /home/<user>/webapps/<app>/artisan lazy-cron:run

All three broken forms fire every minute and fail silently (output goes to /dev/null), so the symptom is a scheduler that never ticks behind a green-looking cron entry. Pick the PHP binary as the vendor and give it just the artisan path — then confirm via the heartbeat (Settings → Advanced shows the banner if cron mode has no live heartbeat) or Setting::get('lazy_cron.last_heartbeat_at') advancing.

Fleet heads also tick on their installs' API check-ins

The trigger below runs on public pages. An Enterprise agency fleet head barely gets those, yet its scheduler (fleet:backup-scheduler, a 60-second task) is what admits the next install during a critical drain — and the eleven installs hitting it were doing so over two API endpoints the page middleware ignores. Since 1.5.3 those endpoints, the license check-in (MembershipCheckController) and the update report (UpdateHealthReportController), call LazyCron::deferRunIfDue() — the middleware's gate + defer(), extracted — on a fleet head only. A nudged install reporting back is what schedules the next one, so the drain mostly paces itself on the run cap rather than on traffic. Ordinary installs and the mothership's key-issuing endpoint are unchanged. Watched live at 1.5.2: four nudges, then a 16-minute stall until a page view, then three more; one homepage request nudged the last pair within a second.

A fleet head still needs a real cron

A fleet head must have a system cron. Treat it as a requirement of running one, not a tuning option. The check-in tick narrows the stall window; it cannot close it, because the chain it relies on is one install waking the head for the next install — and that chain has a hole at the end of every batch:

gate() caches its due-count for 60 seconds (GATE_CACHE_TTL), and a completed run clears it. So the moment the scheduler finishes a batch, the gate repopulates with due_count = 0 — fleet:backup-scheduler has just run and is not due again for another 60 s. Every check-in arriving in that window reads the cached zero and defers nothing. If the batch that just finished is the one whose reports were going to wake the head, there is nobody left to call: the installs it nudged have all reported, and the rest are not due to poll for hours.

Observed on webprojoe, 2026-09-10, draining the critical 1.5.15 at a cap of 2:

19:37  gohce, vfw          19:41  spark, dfma        19:46  rw, darla
19:52  core, bkz           19:56  homesupply, aacubo  ← last reports at 19:56:07/09
                            ccs (11th of 11) — never nudged; head did not tick again

Both closing reports landed inside the 60-second window in which nothing was due. The eleventh install sat on the previous version with every gate green — critical-queued, auto-update on, nudge URL present, no backoff, ring occupancy 0 of 2 — waiting for a tick that only a visitor could produce. A real cron makes the 19:57 tick unconditional and CCS goes out with the rest.

Two things follow for anyone operating a head:

  • Add the cron when you enable Fleet Management, using the managed-host guidance above — but leave the mode on auto. A head in auto is dual-stacked: the cron gives it a dependable minute heartbeat while page views and its installs' check-ins remain a second way to get ticked, and only auto gets the event-driven admission in {@see LazyCron::deferTaskNow()}. Switching to cron trades both away. You lose nothing by staying: a fleet head is held to STALE_HEARTBEAT_MINUTES_FLEET_HEAD (10 minutes) in either mode, because what makes a head's silence urgent is who is waiting on it, not how it is driven. Reading that threshold off the mode alone is exactly what let a head look healthy for a day.
  • Don't read a quiet head as a healthy one. The fastest check is the nudge ladder itself: MemberInstall::orderBy('last_update_nudge_at','desc')->get(['host','version','last_update_nudge_at']). A clean batch-sized ladder that simply stops means the nudger died mid-rollout — no install is at fault.

How a public-page hit triggers cron work

GET /blog
  ↓
[web middleware: LazyCronTrigger::handle()]
  → $next($request) — Spatie response cache may return a cached body
  → LazyCron::gate()  ← single Cache::get, TTL 60s
      {mode: "auto", due_count: 3}
  → defer(fn () => LazyCron::run(5.0), 'lazy-cron')
  ↓
[response shipped to browser via fastcgi_finish_request()]
  ↓
[deferred closure on the same FPM worker]
  → Cache::lock('lazy-cron:exec', 60)->get(...)
  → for each due task: Artisan::call($task->command); update next_run_at

Key guarantees

The page renders independently of the cron. defer() runs the closure AFTER fastcgi_finish_request() ships the response — the browser has already received the full HTML and closed its TCP connection when the cron work begins. The PHP-FPM worker is busy until the closure finishes (a few ms typically, capped at 5s by the budgetSeconds argument), but the user is gone by then.

The homepage never triggers it. / is in the default exclude_paths. The CMS homepage is the page most likely to be measured against PageSpeed benchmarks, so we keep it free of even the one cached gate-read.

Page editor + preview never triggers it either. Both dashboard/* (where the editor lives) and design-editor-preview/* (the editor's iframe) are excluded by default — saving a page or scrubbing through preview never queues cron work on the same worker that's serving the editor's autosave traffic.

The gate is cached for 60 seconds. The lazy-cron:gate snapshot (mode + due-count) is stored via Cache::remember(..., 60, ...). The underlying SELECT runs at most once per minute regardless of traffic. On Redis/Memcached/array cache the hot-path lookup is sub-millisecond and zero DB queries; on the database cache driver it's one indexed PK lookup per minute, then cache hits for the rest of the minute.

Disabled mode bypasses the system entirely. When trigger mode is disabled, the middleware still does its one cached gate read but sees mode = "disabled" and returns immediately — no defer(), no runner. The lazy-cron:run CLI also returns immediately. Tasks accumulate "due" status forever until mode flips back to auto or cron.

Cron mode short-circuits the page-load path. Once the gate cache has the new mode (within 60 seconds of the admin flipping the radio), the middleware sees mode = "cron" and returns without queueing anything — no task ever executes inside a visitor's worker. It is not literally zero work: deferRunIfDue() reads the cached gate first, so a page view still costs one cache read. That read is the same in both modes (the dueNow() count sits behind the same 60-second cache either way); what LAZY_CRON_MODE=cron in .env removes is the Setting lookup inside mode(), which resolves from config instead.

Heartbeat banner detects a broken cron setup. Every call to LazyCron::run() writes the current unix timestamp to Setting::lazy_cron.last_heartbeat_at. The dashboard layout shows a yellow banner when trigger mode is cron AND the heartbeat is more than 10 minutes old (or has never fired) — typically meaning the admin flipped to cron mode without actually adding a crontab entry, or their real cron has stopped firing. The check only surfaces in cron mode, so auto and disabled modes never show the banner.

Trigger modes

Set in Dashboard → Settings → Advanced → Lazy Cron, or via LAZY_CRON_MODE in .env (hard override; UI radios are disabled when this env var is set).

Mode Behaviour
auto (default) Middleware fires on public pages; CLI also works.
cron Middleware no-ops on every request (zero per-page overhead). Admin must add * * * * * php artisan lazy-cron:run to the server's crontab. The Settings page surfaces the exact command to copy.
disabled Both middleware AND CLI no-op. Tasks accumulate "due" forever. Use only for debugging.

When you flip to cron, the gating-snapshot cache (lazy-cron:gate, 60-second TTL) is invalidated so the new mode applies immediately on the next page hit.

Path gating

The middleware skips when any of these apply:

Condition Why
Response status is not 200 Don't trigger on redirects, 404s, validation failures.
Request is AJAX / JSON Livewire round-trips fire constantly; gating to full page loads keeps the heartbeat at human cadence.
Path matches an exclude pattern Homepage, dashboard, page-editor preview iframe, and internal CMS routes are always excluded. Admins can add more from the Settings UI.
Gate snapshot says mode !== 'auto' or due_count === 0 Nothing to do this minute.

Default exclude paths:

Pattern Reason
/ Homepage — perf-critical, the page that hits PageSpeed benchmarks most often
dashboard, dashboard/* Admin pages — covered by the editor (which sits inside dashboard) and we don't want lazy cron firing on the same worker that's saving an editor draft
design-editor-preview/* Page-editor preview iframe — same reason as above
_* Internal CMS routes (e.g. /_cookie-consent/init)

Multi-server safety

Two WebProCMS servers sharing a database must not double-run scheduled tasks. Lazy cron uses three layers to prevent that:

  1. Cache::lock('lazy-cron:exec', 60) — distributed lock. With Redis or Memcached as the cache backend, this is a true cluster-wide lock and only one server enters the runner per minute. With the database cache driver it falls back to row-level locks. With the file cache driver it's per-machine only — but a multi-server install almost certainly has Redis already.
  2. Per-task optimistic re-check — even after acquiring the exec lock, the runner re-reads each task's next_run_at immediately before invoking it. If another server (with a different cache backend, say) raced past in the same window, the re-check sees the updated next_run_at and skips.
  3. updateOrCreate on registration — the registration upsert is keyed on command (unique constraint) so multiple servers booting at once converge to the same row instead of inserting duplicates.

For most installs the cache lock alone is sufficient.

Execution paths

Tasks are stored in one place (scheduled_tasks table) and can be driven by any of three triggers — admins choose based on what their host supports. No matter which path fires, the same LazyCron::run() reads the same table and updates the same next_run_at timestamps, so flipping between approaches doesn't lose or duplicate work.

Setup Mode What fires the runner
No system cron at all auto (default) Public-page traffic. Hits on excluded paths (homepage, dashboard, editor preview) don't trigger; everything else does, subject to the 60s gate-cache TTL.
* * * * * php artisan schedule:run in crontab auto or cron The Laravel scheduler tick in routes/console.php calls LazyCron::run() every minute. In auto mode the middleware also runs (Cache::lock + next_run_at re-check de-dupes). In cron mode the middleware is a no-op and only schedule:run drives execution.
* * * * * php artisan lazy-cron:run in crontab auto or cron The CLI directly calls LazyCron::run(). Identical effect to the schedule:run path; just doesn't require Laravel's scheduler to evaluate every other registered task to find the lazy-cron tick.

Budget differs by trigger. The middleware runs each pass with a 5-second soft budget — it's borrowing a live web worker, so it stays short. The lazy-cron:run CLI defaults to a 60-second budget (--budget=<seconds> to change) since a dedicated cron process can afford to clear a larger backlog in one pass. Either way the budget only caps a single pass; anything still due rolls to the next one.

Practical guidance:

  • New install, can't or won't set up cron? Leave mode = auto. Done.
  • Already have * * * * * php artisan schedule:run running? Mode = auto is still the friendliest (covers low-traffic admin dashboards with no public traffic between 03:00 and 09:00). Mode = cron is fine if you'd rather skip the per-request gate-cache lookup.
  • Want to add a fresh cron entry just for this? Either lazy-cron:run or schedule:run works. Pick the one that already exists in your stack.

What runs

Tasks are registered by:

  • Core (AppServiceProvider): content:publish-due (60s), cms:check-updates (6h — the apply task, kept in step with cms:check-membership so it can apply as soon as the poll learns of an update), features:check-updates (24h — polls external GitHub feeds, so kept daily), errors:prune (24h), errors:digest (24h, min_hour=8), content-overrides:gc (24h — sweeps orphan content_overrides whose row or item is no longer in any blade), translations:sync (24h, min_hour=3 — brings a multi-language site's translations back in step after the base language is edited; returns immediately on a single-language install), inline-livewire:gc (24h), db:optimize (7d), backups:offsite (5m — advances the off-site upload queue), backups:run-scheduled (~1h — recurring snapshot backup; self-gates on the admin's frequency/day/time, on by default). A row whose command no longer exists in the codebase (e.g. left behind by a CMS update that deleted or renamed the command) is pruned automatically the next time it comes due.
  • Feature modules: each provider registers its own tasks on boot. Analytics registers 3 (analytics:prune-visitors, analytics:prune-views, analytics:prune-realtime); Activity Log registers activity-log:prune.
  • Memberships (core) (MembershipsServiceProvider): cms:check-membership (6h) — the combined license/update phone-home. This poll is what learns of an available update, so it sets the ceiling on update-notification latency; it's kept in step with cms:check-updates. Registered on every install, keyed or keyless (keyless installs check in so the mothership can see unlicensed installs and their update status stays fresh); only the license server itself skips it, since it would just be calling itself. Plus the fleet-server-only tasks: fleet:probe + fleet:alerts (30-min pulse), fleet:digest (weekly), and fleet:backup-scheduler (60 s — assigns staggered backup slots and nudges each managed install at its slot; see fleet-dashboard.md → "Backup scheduling"). All no-op instantly off the fleet server (Membership::isFleetServer()), so the rows are harmless on ordinary installs.

Registration is process-memoized: LazyCron::register() only does the DB upsert once per PHP-FPM worker. Every subsequent boot on the same worker is a free in-memory hit. So having a dozen tasks registered across providers costs ~12 upserts on the first request that a fresh worker handles, then zero for the next several hundred requests until the worker recycles.

Task lifecycle: success, failure, and pruning

Every task row carries last_status, last_run_at, next_run_at, and last_error so the Admin UI can show exactly what happened on the last pass:

Outcome last_status Effect
Ran cleanly ok last_run_at = now; next_run_at = now + interval; last_error cleared.
Threw an exception error last_run_at is left unchanged (so you can see it hasn't succeeded), but next_run_at is bumped one interval and last_error stores the message (truncated to 2000 chars).
Killed by a fatal running The claim written before the call stands: next_run_at was already bumped one interval, and last_run_at stays stale — the honest signal that the task starts but never finishes.
Handed to the queue worker queued next_run_at bumped immediately and a queued_at marker is set until the worker picks it up — see Queue worker offload.

The key failure guarantee: a broken task can't hog the runner. The runner claims each task — bumping next_run_at one interval and setting last_status = 'running' — before invoking it, then stamps the outcome after. So a chronically-failing task retries at most once per interval, not on every single public-page hit, while its last error stays visible to admins in the meantime. Failures are also isolated: one task throwing doesn't abort the pass, so the other due tasks in the same run still fire.

Claiming up front is what makes a fatal survivable. An exception unwinds into the runner's catch, but a task that blows PHP's max_execution_time — a long batch of image encodes or page renders — never returns to the runner at all. Were next_run_at written only afterwards, it would stay in the past: the task remains permanently due, the very next public request defers another pass, and the same doomed batch re-runs on page view after page view, tying up a request worker each time for as long as the limit allows and never advancing last_run_at. (This is not hypothetical — it is what stalled the image warmers on a large real-estate install once AVIF encodes joined the ladder.) Batch tasks pair the claim with a wall-clock deadline of their own so they stop cleanly instead of being killed; see ExecutionDeadline.

Self-pruning missing commands. If a task's command no longer exists in the codebase — e.g. a CMS update deleted or renamed it — the runner catches the CommandNotFoundException, deletes the orphan row, and moves on. If the command later returns, the owning provider's register() call re-creates the row on the next boot. No manual cleanup, and no orphan row logging a warning forever.

What does NOT run on lazy cron

These remain on Laravel's regular scheduler (set up via Schedule::command(...) in routes/console.php) and require a real * * * * * php artisan schedule:run cron entry to fire:

  • The scheduled-backup closure in AppServiceProvider::registerScheduledBackup — closures don't have command names so lazy cron can't drive them.

If an install relies on either of these features, it needs a real cron entry. Otherwise lazy cron alone is enough.

Queue worker offload (opt-in)

Installs that run a real queue:work process can hand due tasks to it instead of executing them inline — freeing the PHP-FPM worker (or the cron CLI) immediately and giving long tasks proper worker timeouts. Toggle: Dashboard → Settings → Advanced → Lazy Cron → Run tasks on the queue worker (lazy_cron.queue_offload Setting, default off).

The offload is evidence-gated, never assumed: a due task is only dispatched when the toggle is on AND the queue driver is async-capable AND a worker heartbeat is fresh (seen within 5 minutes — see Background Tasks). Worker-less installs keep running everything inline even with the toggle on, so flipping it can never strand work.

Loss protection: dispatching stamps a queued_at marker on the task row (and bumps next_run_at so the next tick doesn't double-dispatch); the worker clears the marker at pickup. A marker older than 10 minutes means the dispatch was lost (the worker died between heartbeat and pickup) — the next runner pass reschedules the task, and since the heartbeat is stale by then, it runs inline. Long-running tasks can't trip this because the marker clears at pickup, not completion.

The same heartbeat evidence also drives QueueDispatch::dispatchOrDefer(), used by the worker-optional job sites (AI site generation, theme switching): dispatch for real when a live worker is proven, defer inline otherwise.

Admin UI

Dashboard → Settings → Advanced → Lazy Cron has four blocks:

  1. Trigger mode — radio group (auto / cron / disabled). When mode is cron, the copy-pasteable crontab line shows up.
  2. Run tasks on the queue worker — the queue-offload toggle described above, with the current worker-detection state and a link to Background Tasks.
  3. Registered tasks — table of every task with its source (core / feature:foo / custom:bar), interval, last-run + status, next-run, an enable toggle, and a "Run now" button. Editing the interval saves directly to the row.
  4. Excluded paths — textarea of additional path patterns to skip. Layered with the built-in exclude list — admins can't accidentally lock the system into running on the homepage.

Files

File Role
app/Support/LazyCron.php Registration, mode resolution, gate cache, runner with lock + budget.
app/Models/ScheduledTask.php Eloquent model with dueNow() + enabled() scopes.
app/Http/Middleware/LazyCronTrigger.php Web-group middleware — gates and defers the runner.
app/Console/Commands/LazyCronRun.php lazy-cron:run CLI for real-cron parity.
config/lazy-cron.php Env-driven mode override + default exclude paths.
database/migrations/*_create_scheduled_tasks_table.php scheduled_tasks schema.
tests/Feature/LazyCronTest.php Runner, registration, gate, failure-isolation tests.
tests/Feature/LazyCronTriggerTest.php Middleware gating + ResponseCache-compatibility tests.