Most WebProCMS installs run background work inline — deferred closures after the response is sent, plus Lazy Cron for scheduled maintenance — because shared hosts rarely run a persistent queue:work process. But installs that do run a worker (a VPS with Supervisor, an owner-managed server) had no way to see it: is the worker alive, is the queue backing up, did anything fail? The Background Tasks screen (Dashboard → Develop → Background Tasks, admin and up) answers all three, and degrades gracefully on worker-less installs instead of alarming about a setup that's perfectly normal.
What the screen shows
| Section | What it tells you |
|---|---|
| Queue worker | The active queue connection/driver and a liveness verdict: Active (heartbeat within 5 minutes), Stale (a worker ran before but has gone quiet), Not detected (never seen — the normal fleet state), or Sync driver (jobs run inline, nothing to monitor). Shows the last heartbeat time and the last job a worker processed. |
| Test queue worker | A probe button (hidden on the sync driver): dispatches a trivial job onto the real queue and watches for a worker to pick it up. Confirms end-to-end liveness in seconds, or times out after 15 s — and then sweeps the probe job back off the database queue so worker-less installs don't accumulate junk rows. |
| Queue stats | Pending / in-flight / failed counts and the oldest pending job's wait time, per the driver-agnostic size-inspection API (works on database and Redis queues). A warning callout appears when jobs are pending but no active worker is draining them. |
| Queued jobs | The first 25 rows of the database queue: job class, queue name, age, attempts, waiting vs. processing. |
| Failed jobs | The 25 most recent failed_jobs rows with the exception's first line. Per-row Retry (pushes it back onto the queue) and Delete, plus Retry all / Delete all (both deletes are modal-confirmed). |
| Tasks | Every row in background_tasks (see the task runner): label, type, progress, the driver that last ran it, when it started, a heartbeat / Stale badge, and a link back to the page that started it. Cancel (a live task) and Retry (a failed one — it resumes from its saved cursor, not from scratch) are both modal-confirmed. Retry is refused, with a message saying why, while a newer run of the same singular task is live — reviving the old one would run the two at once. |
| Long-running tasks | Progress bars for any work that self-reports through BackgroundProgress (see below). |
The page live-refreshes every 10 seconds while open.
How worker detection works
There is no reliable "is a process running?" API on shared hosting (exec is usually disabled), so detection is heartbeat-based (QueueWorkerMonitor, registered in AppServiceProvider):
queue:work's loop event fires every poll cycle — even when idle — and stamps a cache timestamp (throttled to one write per 30 s).- Every real queued-job execution stamps it too, and records the job's class name.
- The
syncconnection is deliberately ignored: sync jobs run inline in the dispatching request, and jobs run viadefer()never fire queue events — so neither can fake a live worker on an install that has none.
The heartbeat lives in the cache (database-backed by default), so a CLI worker and the FPM dashboard share state, including across servers with a shared cache backend.
The task runner (background_tasks)
BackgroundProgress says how far along a job is; it cannot make the job finish. The task runner is the durable half: one row per unit of long-running work, and a launcher that picks whichever process on this particular host is actually capable of running it.
It exists because the fleet has no queue worker, and because the three things that can run background work are each unavailable on some hosts. A backup, an AI image, a translate pass or a media migration must survive an FPM worker dying at max_execution_time — which is a FATAL, not an exception, so there is no cleanup block to rely on. The only thing that survives is what was written to the database before the process vanished.
The record
Every task is a row in background_tasks (App\Models\BackgroundTask):
| Column | What it holds |
|---|---|
type |
Registry key (ai-image, backup-create, …) resolved through TaskTypes |
payload |
The immutable arguments the task was STARTED with. Never rewritten. |
state |
The runnable's own resumable cursor — page index, file offset, provider handle, temp path |
status |
queued · running · done · failed · cancelled |
driver |
Who last ran a step: worker · detached · inline · browser · cli |
claimed_by / heartbeat_at |
The claim and the proof its owner is still alive |
attempts / not_before |
The retry ladder: 30 s, then 2 min, then failed |
done / total / message |
Progress, for the bar |
last_error, user_id, origin_url, idempotency_key |
Who started it, where to link back, and what makes a duplicate start return the existing task |
The payload / state split is the load-bearing part: the arguments are always intact, and the cursor always describes work that has already been persisted — so re-running a killed step is safe.
Registered task types
| Type | Started from | Resumes at |
|---|---|---|
ai-image |
Every AI image button (page editor, featured images, branding, media library) | The next shot of a multi-image request; a finished shot is never re-billed |
backup-create |
The Backups page, the backup schedule, the fleet head's backup nudge | The build phase and media-file offset it reached |
backup-restore |
The Backups page (a snapshot, an upload, or an off-site copy) | The phase after the last one it finished (download → extract → database → pages → media) |
site-generate |
Dashboard → AI → Generate Site | The next page; the compile phase publishes what is built |
theme-switch |
Design → Theme (switch or resume) | The theme-switch journal's next phase |
seed-demo |
Tools → Seed demo data | The next unit of the seed plan |
translate-item |
Publishing a content item on a multi-language install | The next language (each language is saved as it finishes) |
translate-page |
A page going live on a multi-language install | The next row; the publish happens only on the final flush step |
translations-fill |
Lazy Cron's nightly translations:fill-nightly, when new-content translation is on |
The next target not yet tried tonight; the slice reports what it tried and the task carries it forward, so nothing is bought twice |
media-migrate |
Settings → Media Storage → Migrate to cloud | The last media id copied |
campaign-send |
Marketing (send now or scheduled) | The campaign's own sent-row ledger — a sent recipient is never re-sent |
email-ai-design |
Marketing → email designer → Design with AI | One provider call; a re-run after the result landed sees its own reply in the campaign's conversation and stops |
prospect-sweep, prospect-audit |
Prospecting (module) | The next grid cell / the next business |
prospect-owner-search-batch |
Prospecting → Find owners on this page | The next business; a search already running for it is waited for, never started twice |
accessibility-scan |
The weekly accessibility scan (module) | The scanner's own target cursor |
re-photo-cache |
Real Estate photo caching (module) | Mirrors CachePhotosCommand's pass; the command owns the cursor |
automotive-feed-sync |
Automotive feeds (module): the automotive:sync-feeds Lazy Cron tick for each due feed, and Sync Now (payload reread: true — the pass reads the current file even when it is the one already read; it still counts as an import only when the file is new, so the staleness clock keeps running; a press while a pass is live joins it as it is) |
The phase it reached (stage → upsert → settle) and a byte offset into the staged feed file; every row is stamped with the pass id, so the settle needs no list of what was seen |
automotive-photo-cache |
Automotive (module): the end of every feed pass that changed inventory, a change to the photo count, and the daily automotive:cache-photos backstop |
The phase (census → fetch → warm) and a vehicle-id cursor; the shared-slide census rides in the state so every step judges against the same one. One pass at a time (fixed key); a feed that imports mid-pass sends it round again |
automotive-lead-forward |
Automotive (module): every captured lead (one task per lead — key automotive-lead-forward:{id}), a Resend on the Leads page, and the automotive:forward-leads Lazy Cron for a delivery that came due |
The lead's forward ledger: translate once (a settled translation is never re-paid), announce the webhook once (a claimed column committed with its delivery rows through Webhooks::dispatchOrFail() — an announcement that can't be written rolls its claim back; the store's copies still go out, then the step fails retryably, so the runner's three attempts retry it before the task fails with the error), then send each due row — claimed, sent and marked one at a time, looking again once a pass is through so a Resend that joined the run goes out in it. A row a dead run left sending is marked unconfirmed — only once it has sat there 10 minutes, as a younger one may be another run's send in flight (the cron re-launch comes after that) — and never sent again on its own. Not pumpable (a provider call can outlast a poll) |
automotive-ad-feeds |
Automotive ad feeds (module), only while the feeds are on: the end of every feed pass that changed inventory, a dashboard vehicle change (one deferred launch per request, however many cars it saved), Rebuild and Save store codes on the Ad feeds page, switching the feeds on (or minting new feed URLs) while a file is missing, the first fetch of a feed URL with the right token before its file exists, and the daily automotive:build-ad-feeds Lazy Cron backstop |
The network being written (Google, then Meta), a vehicle-id cursor, the .building file's byte length and the row / skip counts, saved in one write per step — a killed step truncates the file back to the saved length, so no row is written twice. Each file is renamed over the served one only once its last row is down, so a fetch never gets a half-written feed. One build at a time (fixed key); when the inventory moved while a round ran (a change joined it), it builds again, at most 3 rounds |
automotive-translate-vehicles |
Automotive (module): the end of every feed pass that brought vehicles, a dashboard edit or import that changes a description (one deferred launch per request), saving a published special whose title, short description or disclaimer has a language to fill (not while the site's own new-content translator is on), turning the translate switch on, and the hourly automotive:translate-vehicles Lazy Cron |
Live specials' missing disclaimers, titles and short descriptions first, one call each — re-read at the head of every step (a special published while the pass walks the cars is reached on its next step), and skipped while the site's "translate new content automatically" is on (its own translator owns them) — then a vehicle-id cursor advanced only past a car every language is done with. State: cursor, translated (descriptions), offer_texts (special-offer fields), failed, skipped, capped. A translated description or special-offer field reads as current, so a re-walk after a kill buys nothing twice. Each call is claimed against the daily cap (a Setting on the site clock) before it is made; a description or special-offer field that failed is remembered against its English and skipped until its retry wait is over (24 h after a refusal, 1 h after no answer, doubling, at most 7 days). Progress and the daily-limit message count descriptions and special-offer texts. The pass ends at the cap or after three unanswered calls in a row (a refused answer does not count). One pass at a time (fixed key). Not pumpable |
Core types register in AppServiceProvider; a module's in its own service provider. php artisan tasks:demo registers a harmless demo type in its own process, for trying the runner by hand.
The driver ladder
TaskLauncher::start($type, $payload, $opts) inserts the row and then picks a runner, in order. Each rung is a fallback for the rung above being unavailable on this host:
- worker — dispatches
RunBackgroundTaskJob, but only on EVIDENCE: a freshQueueWorkerMonitorheartbeat. Most installs runQUEUE_CONNECTION=databasewith nothing draining it, so dispatching on the mere presence of an async driver would strand the work forever. - detached —
nohup php artisan tasks:run --id=N &viaDetachedArtisan, outside the request entirely. The fleet's OpenLiteSpeed boxes allowproc_open; many cPanel/FPM hosts do not. - inline — one
defer()red slice after the response ships, bounded to ~10 s byExecutionDeadline. Whatever is left is picked up later.
Whichever rung wins, the row is the work. A rung that silently fails to deliver — a worker that dies before pickup, a spawn that gets reaped — just leaves an unclaimed live row, and something else picks it up:
tasks:drainon its next Lazy Cron tick (every 60 s — but Lazy Cron only ticks on public page views).- The browser. A page showing
<livewire:background-task-progress :task-id="…" />polls every 3 s, and each poll may run one ~5 s slice (driver = browser) of a task that is live, unclaimed or stale, and was last run in-request. It never steps a task adetachedorworkerprocess is holding. - A page that nudges a deferred slice. When a step cannot fit inside a 3-second poll (see
pumpable()below), the page that started the task callsdefer(fn () => TaskRunner::runSlice($id))from its own poll instead — off-request and unbounded, but only for a task that is live, not held by a live claim, and on an in-request driver. The task claim is what makes one nudge per poll safe. AI site generation, theme switching, demo seeding, campaign sends and the media migration all do this, because their pages are dashboard paths a Lazy Cron tick would never reach on a quiet site.
The progress component dispatches a background-task-finished browser event ({id, status, message, state}) on the terminal transition, so the page that started the task can show the result.
The contract a feature implements
A feature ports onto the runner by writing one class that implements TaskRunnable:
class BackupCreateTask implements TaskRunnable
{
public static function type(): string { return 'backup-create'; }
public function label(BackgroundTask $task): string { return __('Creating a backup'); }
public function step(BackgroundTask $task, ?float $deadline): StepResult
{
$offset = (int) ($task->state['media_offset'] ?? 0);
// …copy a bounded number of files, stopping on ExecutionDeadline::passed($deadline)…
$task->progress([
'state' => ['media_offset' => $offset + $copied],
'done' => $offset + $copied,
'total' => $totalFiles,
'message' => __('Copying media'),
]);
return $done ? StepResult::done() : StepResult::continue();
}
}
Four rules make a runnable correct:
- A step owns its cursor. Read
$task->state, do a bounded amount of work, write the cursor back withprogress()BEFORE returning.progress()is one save and silently ignores runner-owned columns. - A step must be safe to re-run after being killed at any point after any write. That is the whole design: the runner never rolls a step back, so anything already persisted must not be repeated.
ExecutionDeadline::passed($deadline)is the in-step stop signal. A null deadline (CLI, detached) never passes, so the same body runs unbounded there.- Register the type from a service provider —
TaskTypes::register('backup-create', BackupCreateTask::class); core types inAppServiceProvider, a module's types in its own provider.
Five optional hooks, each detected with method_exists (they are deliberately not on the interface):
| Hook | When it runs | Use it for |
|---|---|---|
cancelled(BackgroundTask $task): void |
On Cancel | Deleting temp files, releasing a feature-side lock |
pumpable(BackgroundTask $task): bool |
Before a browser poll would run a slice | Returning false while the NEXT step cannot finish inside a 3 s poll (a provider call, a whole-tree copy). Decide per step, not per type: backup-create refuses only its database dump, site-generate allows only its compile phase. A type that refuses every step must have a page that nudges a deferred slice, or it strands on a host with no worker and no proc_open. |
failed(BackgroundTask $task, string $error): void |
Once, on the TERMINAL failure — never on a backoff retry, and never when the row was cancelled while the failing step ran | Correcting a compatibility Setting other code reads, e.g. backup_status, which the fleet head reads to size its run cap |
staleAfterSeconds(BackgroundTask $task): int |
Wherever staleness is decided — the claim, isStale(), the browser pump, the Stalled badge |
Lengthening the claim's stale window (default 120 s) for a step that legitimately runs longer — a database import, a big unzip — so no other driver takes the row and runs it a second time while this one is still inside it. Decide per step, like pumpable(): return the long window only for the phase that needs it. Clamped to 120 s – 6 h: never shorter (the drain filters on the default), never unbounded (the window is also how long a dead runner's row waits to be recovered). |
requiredFeature(BackgroundTask $task): ?string |
Before every step | Naming the feature-module key the task belongs to. While Features::enabled() says no — switched off, or dropped by a lapsed plan — the runner pauses the task instead of stepping it: claim released, not_before pushed out 5 minutes, no attempt counted, cursor untouched, message "Paused — the … feature is turned off". Re-enabling resumes it within 5 minutes. Every module task declares it (prospect sweep and audit, accessibility scan, campaign send, photo cache): module providers boot unconditionally, so without it the core drain kept running a disabled module's work. |
Inside a long step, call $task->heartbeat() at natural boundaries (per file, per chunk, per phase) to prove the runner is still alive. It is one conditional UPDATE on this runner's claim, throttled to one write every 5 s so a tight loop is fine, and it returns false once the task is no longer this step's to run (cancelled, or reclaimed) — a long step may stop early on it. It only works on the instance the runner handed to step(); a model loaded any other way has no claim to vouch for. staleAfterSeconds() covers a step that cannot heartbeat (one blocking call); heartbeat() covers one that can, and is the better answer where it is possible.
Four rules came out of porting the existing features, each from a real bug:
- A fixed idempotency key is fine.
'backup-create'for every backup, not one per click. The launcher releases a key held by a terminal row before inserting, so a finished or failed task never blocks the next start. The released row remembers the key ({key}#released-{id}), so a later Retry of it re-claims the key — or is refused while a newer run holds it — rather than coming back as a keyless twin. - No process statics across a step or process boundary. A step can run in a different process from the one before it (a spawn, then a poll, then a drain), so a static memo set in one step is simply absent in the next. Keep cross-step state in
state. - Write terminal compatibility state AFTER the last helper call. A helper that marks its own work "running" will overwrite a
donewritten before it. - Never stamp a status Setting without restore-on-throw. A step that sets
…_status = runningand then throws leaves the Setting lying until someone notices; restore it in afinally/catch, or let thefailed()hook correct it.
Anything metered (managed AI images) sends X-Idempotency-Key: task-{id}-{nonce}-{attempt}[-{shot}], so a re-run of the same attempt replays the broker's cached response instead of being billed again. The {nonce} is random per task and persisted in state before the first provider call (ImageRunContext::NONCE_STATE_KEY): the id alone collides, because every install numbers tasks from 1, a fleet's installs share one license key at the broker, and a backup Restore hands ids out again. The broker also scopes each stored response to the calling install's X-WebProCMS-Install host.
Starting one is then a single call, and the request the browser waits on does no provider work at all:
$task = TaskLauncher::start('backup-create', ['scope' => 'full'], [
'user' => auth()->user(),
'originUrl' => url()->current(),
'idempotencyKey' => 'backup-create', // a repeat start returns the LIVE task, not a twin
'total' => $totalFiles,
]);
Running, draining, pruning
| Command | What it does |
|---|---|
tasks:run --id=N [--max-seconds=1500] [--driver=cli|detached] |
Runs ONE task to completion or the ceiling. What the detached rung spawns, and what an admin types to push a stuck task along. |
tasks:drain [--max-seconds=] |
Runs every due task, oldest first. Registered on Lazy Cron at 60 s — bounded to one ExecutionDeadline slice when it runs from the web, and to --max-seconds per task on a real CLI. |
tasks:prune |
Deletes done/cancelled rows after 7 days and failed rows after 30 (a failure's last_error is what an admin comes back for). Registered daily. |
TaskRunner is the single entry point all of them use. It claims with one conditional UPDATE — never read-then-write, or two processes would both conclude the row was free — stamps a heartbeat before every step, and releases the claim on any ordinary exit, leaving status = running for the next driver to continue. A claim whose heartbeat stops for 120 seconds (or the type's own staleAfterSeconds() window) is assumed dead and may be taken by anything, which is what makes a killed process recoverable with no daemon involved. Every write after the claim — the between-steps heartbeat, done, failed, a backoff, a release — is conditional on the row still being this runner's and still running, so a Cancel that lands while a step runs is final, and a runner whose row was reclaimed never clears the new owner's claim.
TaskRunner::pump($id) is the browser-polling entry point: it runs one very short slice when a task is live and genuinely unattended, and always returns {status, done, total, percent, message, last_error, stale}. It deliberately never fights a detached or worker claim — those processes are already making progress, and a poll that also stepped them would double-run work whose only protection is the claim it would be ignoring.
Self-reported progress (BackgroundProgress)
Queue tables only know pending/reserved/failed — a "63% done" bar requires the work to report its own progress. BackgroundProgress is the one-line-per-milestone registry for that:
BackgroundProgress::start('media-cloud-migration', 'Migrating media to cloud', total: $count);
BackgroundProgress::increment('media-cloud-migration'); // per item
BackgroundProgress::complete('media-cloud-migration'); // or ::fail($key, $error)
Entries live in cache and age out on their own (6 h after the last update), so a crashed job's bar disappears instead of sticking forever. Any job, deferred closure, or artisan command can adopt it incrementally.
It is deliberately advisory-only: last-write-wins, no locking, and nothing about it can resume a job that died. New long-running work should use the task runner above — BackgroundProgress remains for lightweight reporting from work that is already guaranteed to complete in one pass.
Setup
Nothing to configure. Installs without a worker see an informational note (that's the supported default — Lazy Cron and deferred execution handle background work). To run a real worker, start php artisan queue:work under a process monitor such as Supervisor and set QUEUE_CONNECTION=database (or redis) in .env; the screen will show it as Active within seconds of its first poll.
What the heartbeat unlocks
Worker detection isn't just informational — it gates real dispatch decisions:
- Lazy Cron queue offload (opt-in, Settings → Advanced → Lazy Cron): due scheduled tasks are handed to the worker instead of running inline, with automatic rescheduling if a dispatch is lost. See Lazy Cron → Queue worker offload.
- The task runner's top rung (
TaskLauncher): a fresh heartbeat is what makesRunBackgroundTaskJobthe chosen driver rather than a detached spawn or an inline slice. No heartbeat, no dispatch — the ladder steps down instead of stranding the row.
Limitations
- Per-queue row listing covers the database driver; other drivers get aggregate counts for their default queue only.
- The legacy progress list only shows work that calls
BackgroundProgress; everything that runs as a task shows in the Tasks table instead. - Lazy Cron only ticks on public page views, so on a quiet site with no worker and no
proc_opena task whose steps are not pumpable advances only while its page is open (the nudge) or when a visitor arrives. - Scheduled maintenance tasks are intentionally not duplicated here — they live on the Lazy Cron panel (Dashboard → Settings → Advanced), which the screen links to.