Async platforms — when to use which
What this covers / who it's for. The three ways background work runs in this app — plain queued jobs on Horizon, the task-runner execution ledger, and the async-requests poll platform — and the decision guide for picking (or combining) them. Read this before adding any job, sweep, or "kick off and poll" feature.
The decision guide (start here)
| Your work is… | Use | Why |
|---|---|---|
| A fire-and-forget side effect of one action (forward a document, persist an audit row, process a webhook) | Plain queued job on a dedicated Horizon queue | No durable ledger needed; queue-native retries are the recovery story |
| Multi-unit tracked work — "run something for every tenant / every file / every work item" — or a single job that must be durably tracked | Task-runner (D93) | One batch per run, one job per unit, a durable task_runs/task_run_units ledger, per-unit failure containment, operator retry/cancel/stuck tooling |
| A user-visible long-running request the SPA kicks off and polls (exports, statements, computed answers) | Async-requests (D83) | The tenant-scoped pollable request store with a typed link | confirmation | answer result |
| Fleet work whose per-tenant outcome is user-visible | Both — task-runner fans out, each unit job feeds an async request | Monthly statements are the worked example (below) |
The two platforms are deliberately decoupled siblings: task-runner tracks internal execution
(central ledger, operator web-guard surface), async-requests tracks client-visible request
state (tenant-RLS store, client-guard poll). Neither imports the other; a consumer job may
feed both.
(a) Plain queued jobs on Horizon
Six supervisors, one queue each — config/horizon.php carries the
authoritative values and the per-queue rationale comments. Per-queue isolation exists so one
workload's backlog or serialization never head-of-line-blocks another:
| Queue | Supervisor | tries / timeout | Carries | Isolation rationale |
|---|---|---|---|---|
default | supervisor-1 | 1 / 60s | everything unrouted | catch-all |
webhooks | supervisor-webhooks | 5 / 120s | ProcessInboundWebhookJob | upstream is already 200-acked — transient failures must retry, never drop |
documents | supervisor-documents | 5 / 120s | ForwardDocumentToProviderJob, ScanDocumentJob | store-before-forward: retry rather than strand a document in stored |
audit | supervisor-audit | 5 / 120s | PersistAuditEventJob (async success path) | a dropped append leaves no detectable gap — retry then alert |
dlp | supervisor-dlp | 5 / 120s | PersistDataAccessLogJob | every Pii/Secret read logs here; no consumer = silent data loss |
tasks | supervisor-tasks | 1 / 300s | task-runner unit jobs | the deliberately low worker count is the DB-load throttle; tries=1 because units contain their own failures |
waits alerting is 60s everywhere except tasks (900s — sweeps are batch work). Local-dev
caveat: the docker worker service runs plain queue:work on default only; run Horizon
(make artisan ARGS=horizon) to drain the five specialized queues locally.
(b) Task-runner — the durable execution ledger (D93)
app-modules/task-runner/README.md has the specifics;
the task-runner skill
teaches adoption. The shape: any multi-unit background task becomes a Laravel batch with one job
per unit, ledgered on the central task_runs / task_run_units tables (unit tenant_id is
an opaque no-FK bigint — per-unit tenancy is the consumer job's responsibility, initialized in
try/ended in finally).
- Define + register: a
TaskDefinition(type(),units(),makeJob(),options()) registered viaTaskTypeRegistry::register(...)in the consumer's providerboot()— the same registry inversion as webhooks; task-runner never imports a consumer. - Start:
app(TaskRunner::class)->start($definition, $context, initiator: ...)— persists the run + unit rows (chunked), dispatches aBus::batch()withallowFailures(). - Failure containment: a fleet unit's throwable is recorded on its ledger row (redacted
last_error— the dlp module provides theScrubsLedgerTextmasking seam) and not rethrown: one tenant's failure never aborts the sweep. Recovery istasks:retry <run> [--failed-only]or the next scheduled run — never a queue re-attempt. - Throttling: per-type named rate limiter
task-runner.<type>driven byTaskOptions->ratePerMinute; throttled tails are held (not failed) up to theretry_horizon_hoursdrain horizon. - Operator surface: console
tasks:status/tasks:retry/tasks:cancel/tasks:prune/tasks:detect-stuck, plus the read-only backoffice APIGET /api/v1/backoffice/task-runs(+/{uuid}),webguard, permissiontask-runner.runs.view. - Stuck detection:
tasks:detect-stuck(hourly) surfaces units sittingrunningpaststuck_after_minutes(default 120, per-type override) — it never auto-fails them. - Retention:
tasks:prune(daily) force-deletes terminal runs pastretention_days(default 90);queue:prune-batches(daily) trims the underlyingjob_batches.
Adopters today: payments.monthly_statements, provider-mirror.reconcile_stale,
documents.redrive_forward_failed, features.roll_periods (the four converted scheduled
sweeps), plus documents' ForwardDocumentToProviderJob as the tracked single-job case.
Run and unit state machines
(c) Async-requests — the client-facing poll platform (D83)
app-modules/async-requests/README.md has the
specifics; the async-requests-platform
skill
teaches adoption. One tenant-scoped, forced-RLS table (async_requests) behind the published
Integration\Contracts\AsyncRequestPlatform seam — create / markProcessing / complete /
fail, every transition type-checked against the caller's own $expectedType (a foreign type
reads as 404 ASYNC_REQUEST_NOT_FOUND; a terminal row rejects transitions with 409
ASYNC_REQUEST_ALREADY_TERMINAL). The platform runs no jobs and knows no request types — the
consumer owns its job and queue choice.
- Typed result:
link({url, expires_at}),confirmation({confirmed, message}), oranswer(consumer-defined payload). - Client surface:
GET /api/v1/async-requests/{uuid}(poll) andGET /api/v1/async-requests(filtered, cursor-paginated list), bothclientguard + permissionasync-requests.view. - Retention:
async-requests:prune(daily) force-deletes terminal rows whosecompleted_atis pastretention_days(default 30 — distinct from task-runner's 90); it never touches object-storage artifacts. - Live consumers today: payments only —
payments.transactions_exportandpayments.monthly_statement, both completing with alinkto a signed, permission-gated download. (Notification CTAs can point at a poll uuid as plain data, but notification is not a platform consumer.)
Combining them: monthly statements, the worked example
payments:generate-monthly-statements (scheduled monthly) starts the task-runner type
payments.monthly_statements — one unit per eligible tenant. Each unit job
(GenerateTenantMonthlyStatementJob) is tracked on the task-runner ledger and creates,
processes, and completes that tenant's payments.monthly_statement async request with a link
result. Operators watch the fleet on the task-runs ledger; each tenant polls their own request.
Mind the two nearly-identical type keys:
payments.monthly_statements(plural — the task-runner type) vspayments.monthly_statement(singular — the async-request type).