v1.7.4 · stable
Askr · The Production Server 1.0 · Stable

The real server
for Laravel & PHP .

Askr embeds the PHP interpreter in-process — no FastCGI, no FPM — and serves it from a memory-safe Rust hot path. In worker mode it boots your app once and serves many requests against it, eliminating per-request framework bootstrap. TLS, HTTP/2, static files, worker supervision and an admin dashboard in a single binary. Now 1.0 — the stable surface (CLI, config, askr_* bridge and HTTP headers) is frozen under SemVer.

askr serve — Laravel 13 + Livewire
# Boot once, serve many — worker mode
$ askr serve --root public \
    --worker-script examples/laravel-worker.php \
    --workers "$(nproc)" --tls-self-signed
✓ 8 workers booted · listening on :443 (HTTP/2)
✓ 300/300 requests → 200 · zero state bleed
  → each worker booted exactly once
Real Laravel 13 + Livewire

per-request

~110 ms

worker mode

~9 ms

speed-up

~9×

throughput (8 workers) 37 → 347 req/s

Verified correct under load. Raw embedding overhead is ~0.02 ms/request — the framework bootstrap is the cost, and worker mode removes it.

See the benchmarks
Download · v1.7.4

Download Askr

Self-contained Linux tarballs — the binary, embedded PHP, opcache and examples in one download. Or grab the Docker image, or build from source.

All releases

On 1.7.2 or earlier? Upgrade to 1.7.4 — for the TLS fix, and, if one instance serves several sites, for what they were sharing.

1.7.3 moves rustls to 0.23.45 (RUSTSEC-2026-0285, medium). rustls accepted TLS 1.3 handshake messages sent at the wrong encryption level when they followed a key-changing message in the same record, where RFC 8446 requires the connection to be torn down. The transcript is still authenticated, so a handshake cannot be altered or completed with it; the practical effect is that a peer could send in plaintext what should be encrypted and not be refused. Askr uses rustls to terminate TLS and, as its ACME client, to fetch certificates. The same release closes the last place where a process with no application set could act on another application’s data — acknowledging its queue job with a guessed lease. No serving process could reach it; it is closed because that is exactly the assumption that has failed before.

Several sites on one instance? Until 1.7.2, one site’s cache:clear — Laravel’s Cache::flush() through the Askr driver — emptied the response cache of every application on the instance, because entries did not record whose they were; each now carries its application, and a flush takes only the caller’s pages. The durable SQL backends are a different matter, and not fixed: ASKR_CACHE_DB, ASKR_QUEUE_DB and ASKR_BROADCAST_DB are not separated per application the way shared memory is, so sites that use the same names read each other’s cache, run each other’s jobs and receive each other’s broadcasts. Since 1.7.4 Askr says so at startup and in askr config-check, naming what is shared. Nothing is refused; give each application its own Askr instance and its own database files.

Also: behind a proxy? Until 1.7.1 PHP saw the proxy’s address as the client, and forwarding headers from anyone reached your app. Askr already knew the real client — its rate limiter resolved it through trusted_proxies — and then handed PHP the TCP peer as REMOTE_ADDR. It was found behind nginx in front of a container: the peer was the Docker gateway, so an IP allowlist that waives 2FA for known addresses asked every visitor for a second factor, and started working again the moment nginx was taken out of the path. 1.7.1 resolves REMOTE_ADDR with the same function the rate limiter uses, and keeps the proxy in ASKR_PEER_ADDR.

Fixing the variable alone would have left the larger hole. Laravel’s trustProxies(at: '*'), common in containers, takes the leftmost X-Forwarded-For entry, so a visitor could name their own address — and X-Forwarded-Host from a peer nobody vouched for is how a password-reset link gets pointed at somebody else’s domain. From a trusted proxy Askr now collapses the chain to the one client it resolved; from any other peer it removes every forwarding header, Forwarded included. It logs the first time it does, because a deployment that kept its proxy trust in the application rather than in Askr breaks there, and that break should name itself. Put your proxy’s address in trusted_proxies before you upgrade.

Also: running [[site]] with a queue or scheduler sidecar? Your queue has been dead since 1.5.1, and nothing told you. Upgrade and set [queue] root. Shared memory is namespaced per application, derived from the docroot, and [[site]] gives every virtual host its own — but the queue and scheduler sidecars took the namespace of the top-level [server] root. A sidecar rooted at one application cannot see another’s jobs. Not slowly: never. A production instance accepted every queued job for six days and read none of them — all outbound mail, every webhook, every queued broadcast — and it surfaced because somebody could not reset their password, the reset email being one of the jobs nothing ever read. The admin API showed the jobs waiting with reserved: 0 on every lane. 1.5.1’s upgrade note described this behaviour accurately and filed it as a design decision. 1.7.0 gives the sidecars [queue] root and [scheduler] root, and refuses to start when [[site]] meets a sidecar with neither set: Askr cannot infer which application a queue script belongs to, and the previous default was a guess that failed in total silence. Refusing costs one line of config. Guessing cost six days.

On 1.5.1 itself? That one answered 400 to every HTTP/1.x request to a Laravel or Symfony app, and the bug is ours. 1.5.1 taught the $_SERVER builder to join repeated header fields, which the HTTP/2 cookie fix in that release needed — but HTTP_HOST was already set from the effective host before that loop ran, and the loop re-added it from the Host: header it had been derived from. The join turned the two into works.example, works.example:443; Symfony treats a comma in the host as suspicious and threw before the kernel saw the request, so Laravel answered 400 to /, to /up, to a login POST, to everything. HTTP/2 was unaffected — hyper carries the authority in :authority and there is no Host: field to duplicate — which is why the cause looked like it had to be in the request URI, and wasn't. The duplicate had been there, invisible, for as long as the header loop had; the join is what made it fatal. Why the tests missed it: all 22 e2e tests drive real HTTP/1.1 through a real worker and passed, because their worker scripts echo a string and never look at the host. The transport was never the gap; the assertion was. An e2e worker now validates the host the way a framework does, and against the 1.5.1 code it reproduces the production failure verbatim. Over HTTP/2 a non-default port now appears in HTTP_HOST where it previously didn't — Upgrading says so.

Running queue workers, TLS, or Laravel in worker mode? Also check one setting. If you run queue workers, check that [queue] slots (or --queue-slots) is set. Without it the ring is never mapped, askr_queue_push() returns 0, Laravel does not check that return value — and every queued job was discarded. No exception, no log line, and nothing left in the queue to age or to notice. Password resets, invitations and all outgoing mail simply stopped existing, while queue workers polled a ring that was not there. 1.4.13 refuses to start in that configuration instead. There is nothing to recognise here, so it is a configuration to go and read rather than a symptom to match.

1.4.x fixed a run of correctness bugs that failed silently and therefore look like a bug in your application: an anonymous visitor served as a logged-in user, interactivity that dies after the first page load with a clean console, 419 on every form, empty downloads that answer 200, localhost in generated URLs over HTTPS, and — through 1.4.11 — a hard 500 on Fortify's two-factor enrolment pages, because iconv was compiled out of the bundled PHP and the QR library needs it. If you recognise any of those, the symptom index names the cause and the version that fixed it.

Over TLS there is one more with a consequence worth spelling out: through 1.4.6, HTTP/2 lost the host a request was addressed to — and ALPN picks h2 by default — so with [[site]] vhosts a request for one domain could be served another's application, and two domains could share response-cache entries.

Since 1.4.11 you do not have to recognise any of it from a list. scripts/smoke.sh <url> checks a live deployment for every one of these failures and exits with the count, and askr doctor --app <path> checks the application against the environment before it serves anything. Both are in the release, and both were written because the failures on this list were silent.

One correction, if you are on 1.4.13 or earlier. There doctor --app read .env from disk — which in a container is the source that loses, because Laravel's Dotenv will not overwrite a variable that already exists, so the real environment wins. Run against a Docker deployment it could report on values the workers never see. Its scheduler check also matched any method named command(), including Artisan::command(), which schedules nothing, so it could warn that scheduled tasks would fail on an app that schedules none. Both are fixed in 1.7.4: values resolve the way the application resolves them and every finding names its source, and the scheduler check is anchored on Schedule::command(. That last one is a trade rather than a free improvement — it will miss scheduling registered from a service provider — taken because a check that cries wolf gets skimmed, and then the real findings beside it are skimmed too.

Three things upgrading the server will not do for you.

  • Several fixes live in examples/laravel-worker.php. If you copied that script into your project, re-copy it — a new binary alone changes nothing.
  • On Laravel 13, run composer update kwhorne/askr-laravel. The package was fatal on any page that touched the queue — mail, most visibly — because Laravel 13 added four methods to the Queue contract, and a class missing an abstract method fatals at load time. That one is a package fix with no server change at all.
  • Flush the response cache if you host more than one domain, and reissue certificates if that machine has users you would not hand one to. Both are things a fix changes going forward without undoing what is already stored.

The upgrade guide has the commands and the root cause of each.

Hand-writing long cache tag lists? Read this before upgrading.

Through 1.3.0, a response carrying more cache tags than an entry can hold kept the first eight and silently dropped the rest — so askr_cache_forget_tag() could never reach the dropped ones and the page stayed stale until its TTL expired. Serving stale content with no error is the worst failure a cache has.

1.4.0 refuses to cache that response instead, warns once, and counts askr_cache_tag_overflow_total on /metrics. So you may see a page stop being cached after upgrading. That page was already broken — it just failed quietly. Tag by class rather than per instance, or cache a smaller fragment with ESI. Details in the upgrade guide.

Or run it in Docker
ghcr.io/kwhorne/askr · amd64 + arm64
# Official multi-arch image on GHCR
$ docker pull ghcr.io/kwhorne/askr:1.7
$ docker pull ghcr.io/kwhorne/askr:1.7.4   # exact
$ docker pull ghcr.io/kwhorne/askr:latest

# Layer your app onto the Askr runtime
FROM ghcr.io/kwhorne/askr:1.7 AS runtime
COPY --chown=askr . /var/www/app

One container, the whole stack

Web workers, queue, scheduler, cache and broadcasting run in one process tree — replacing app + Nginx + Redis + queue + cron. Ships a HEALTHCHECK, graceful drain on SIGTERM, non-root, and cgroup-aware worker sizing.

Bind-mounting your app on Linux? The container runs as uid 999 and a bind mount keeps the host's ownership, so Laravel cannot write storage/ — and the symptom is misleading: every PHP route 500s while static files serve normally, because Monolog fails during bootstrap. Add user: "1000:1000". macOS hides this completely, so a compose file that works on your laptop can fail on a server for this reason alone.

Docker guide
S.01 — What it does

One binary,
no proxy in front.

Auto-TLS via ACME, HTTP/2, br/gzip compression, a Varnish-grade response cache with ESI and PURGE/BAN, a Pusher-compatible WebSocket, a shared cache with locks, sessions and a job queue, SSE broadcasting, a scheduler, Prometheus metrics and a seccomp/Landlock sandbox are all built into the Rust core. One container replaces the usual app + Nginx + Varnish + Redis + queue + cron stack — on a single box Redis is fully replaceable.

Worker mode

Octane-style boot-once-serve-many with per-request state reset — no bleed. Eliminates framework bootstrap on every request. As of 1.4.5 the output layer is reset per iteration too, and an exit() or an escaping exception costs that one request rather than the worker — so file responses, downloads and streamed exports no longer end the process serving them.

Varnish-grade cache, in-process

header("Askr-Cache: 60, tags=posts") serves cacheable pages straight from Rust at static-file speed — with stale-while-revalidate, stale-if-error saint mode, and cache-key normalisation that stops tracking parameters fragmenting the cache. Declarative [[cache.rule]] blocks set policy per path for apps you can't edit. Invalidate by tag from PHP, or over HTTP with PURGE and BAN. The cache can also survive a restart — [cache] persist writes the region on graceful shutdown and loads it at boot, so a deploy does not pay a cold-cache stampede, with tag generations saved alongside the entries. No extra hop, and tag invalidation Varnish doesn't have.

ESI — cache pages with holes

One dynamic widget no longer makes a whole page uncacheable. Mark a response Askr-ESI: on, drop in <esi:include src="/_esi/cart"/>, and the shell caches for an hour with its tags intact while each hole is rendered per request — with its own TTL, tags and PURGE. A failing fragment logs and leaves the hole empty rather than taking the page down; src must be a same-origin path, so an ESI tag can never become an outbound fetch.

Pusher-compatible WebSocket

A drop-in Reverb (--pusher): WS /app/{key} + trigger endpoint. Laravel Echo works with no frontend change, with private/presence auth. One thing to know if you test it with a CLI client: the upgrade needs HTTP/1.1, because HTTP/2 forbids the Connection and Upgrade headers — so over h2 the request falls through to the front controller and Laravel answers a perfectly correct 404 with nothing in the log. Browsers are unaffected, since Echo and pusher-js use the WebSocket API, which speaks 1.1 for this regardless of the page protocol. Askr-49 tracks the RFC 8441 fix; answering 426 instead was rejected because /app is a common Laravel route namespace and the heuristic would turn working pages into errors.

askr_defer()

Run work after the response is sent — email, webhooks, logging — before the worker takes the next request. Octane-style deferred work, no queue.

Elastic autoscaling

In CoW mode (--workers-min/--workers-max) warm workers are added on a live queue-depth signal and harvested when idle — practical only because respawn is ~ms.

Record & replay

A 5xx persists its full CGI envelope (--record-errors); askr replay re-runs the exact request against a fresh interpreter. Recent failures on the dashboard.

Traffic shadowing

--shadow-to <url> mirrors a sampled fraction of safe (GET/HEAD, cookie-less) requests to a staging deploy after serving the real response, and compares status + body — divergence is counted on /metrics. The client never waits; the shadow never sees writes.

Leak-aware recycling

--max-rss <MB> predictively recycles a worker before it hits the memory limit, so a leaky app never OOMs mid-request — the drain is graceful and a fresh worker takes over.

Parallel test runner

askr test boots once and forks a warm, isolated process per test file (PHPUnit/Pest) — the CoW substrate applied to your suite.

Multipart file uploads

multipart/form-data is streamed straight to temp files (constant memory), handed to PHP as $_FILES — $request->file("avatar")->store() just works in worker mode. Runs any Laravel app.

br / gzip compression

Compressible responses are encoded in the Rust hot path — br (preferred) or gzip from Accept-Encoding — often 5–10× fewer bytes. Pure-Rust encoders, self-contained build unaffected.

One container, whole stack

An official multi-arch image on GHCR (amd64 + arm64): web workers, queue, scheduler, cache and broadcasting in one process tree, with HEALTHCHECK, graceful drain and cgroup-aware worker sizing.

askr tune

Stop guessing worker counts. askr tune runs your front controller in-process and measures boot time, wall versus CPU time per request — that ratio is what decides whether more workers than cores will help — plus memory growth, then prints an askr.toml with one line of reasoning per number. It ends by stating what it did not cover, because a confidently wrong max_rss_mb buys you a recycling storm.

PHP 8.5, process per core

The embedded engine is PHP 8.5 (non-ZTS) with OPcache + JIT built in, tuned for Laravel 13 — one worker process per core on a shared listen socket, real multi-core parallelism without threads.

Paranoid mode

--paranoid is a state-bleed detector that tells you whether your app is actually worker-safe before you ship it.

CoW template

Boot once, fork workers with copy-on-write for ~ms warm respawn and shared memory — the substrate behind autoscaling and the test runner.

Queues + scheduler

Queue workers and the scheduler are supervised in the same binary — no separate Horizon or cron. Workers autoscale on the shared-memory backlog between --queue and --queue-max (Horizon balance=auto, but native), draining as it clears.

Redis-free, on one box

The in-binary shared cache backs Laravel cache, atomic counters, atomic Cache::lock(), sessions (large region) and a shared-memory job queue — replacing Redis for cache, locks, sessions, pub/sub and queues — and, with fleet-wide rate-limit buckets, for throttling too.

Durable L2 tier (opt-in)

Optional durable, replicated cache, queue and pub/sub backends over SQL Anywhere (--features sql-backend + ASKR_*_DB) implement the conformance-tested contracts verbatim — with L1→L2 write-through for hot reads — behind the unchanged askr_cache_* / askr_queue_* / askr_broadcast() API. This completes the Redis-free Laravel surface: session + cache + queue + broadcasting. Unlike shared memory, the L2 tables are not separated per application, so an instance serving several sites should not use them — since 1.7.4 Askr warns at startup when it would, naming what is shared. Ships precompiled in the -full tarball and ghcr.io/kwhorne/askr:full image — no building from source.

Runs any Laravel app

The Linux libphp bundles intl, gd, curl, zip, exif and pdo_mysql/pgsql — heavy apps like Filament run out of the box. askr doctor reports the recommended extension set.

Supervised sidecars

Run helper processes — e.g. Inertia SSR (node bootstrap/ssr/ssr.mjs) — alongside the web, queue and scheduler slots: spawned, respawned on crash, and drained on shutdown.

Broadcasting via SSE

Live updates to browsers with askr_broadcast() over Server-Sent Events — no Reverb, no Pusher.

Observability to SQL

Stream a structured per-request log to ElyraSQL (or any MySQL-wire database) and query it with plain SQL in Conductor or a BI tool — plus a periodic metrics rollup table (p50/p95/p99, inflight). Non-blocking, batched, drops under backpressure — in the prebuilt -full build.

OpenTelemetry traces

Export OTLP/gRPC traces (--features otel) to Jaeger/Tempo/the OTel Collector: a root http.request span with a child php.execute span timed to the exact PHP window — so “PHP is 99.5% of the request” is visible per request — and a response.build span. On a background batch processor, never touching request latency.

Streaming HTTP/3 (QUIC)

Serve HTTP/3 over QUIC on the TLS port alongside HTTP/1.1 + HTTP/2 (--http3), sharing the same certificate and request handler — PHP just sees SERVER_PROTOCOL=HTTP/3.0. Responses stream frame-by-frame (SSE, large files, chunked APIs) with no buffering; a SO_REUSEPORT UDP socket per worker; clients upgrade via Alt-Svc.

Virtual hosts + redirects

One instance serves many domains — each [[site]] routes a hostname to its own document root and app, so no more one Askr per site and no nginx/Apache vhost layer in front. Until 1.5.1 that separation stopped at the shared memory: the cache, sessions and job queue were one region per instance, so one site could read another’s by key or log them all out with a single flush. Each application is namespaced by its docroot now — automatic, nothing to configure — so two domains on one docroot share and two docroots do not. A built-in redirect engine does www→apex and http→https, and a single multi-SAN cert covers every domain, auto-reloaded on renewal. The http→https half only became real in 1.4.2: force_https could never redirect somebody who typed http:// while Askr terminated TLS itself, because a TLS listener never sees a plain-HTTP request and the ACME challenge server only existed during an issuance — so the recommended setup was the one configuration that could not have the redirect every nginx config has. A plain-HTTP listener now lives for the whole process and does both jobs: HTTP-01 challenges, and a 308 to the same host, path and query for everything else.

Automatic TLS (ACME)

rustls + ALPN HTTP/2, --tls-self-signed for dev, or auto-TLS via ACME/Let’s Encrypt with HTTP-01 challenges and zero-downtime renewal — the last piece of “single binary, no proxy”. From 1.4.10 there is an [acme] config section, so auto-TLS and a config file are no longer mutually exclusive: trusted_proxies has never had a flag, which meant “auto-TLS behind a proxy” could not be written down at all. The section refuses to start on the mistakes that would otherwise end with a site quietly serving plain HTTP — domains without enabled, since TOML defaults a missing bool to false and the file then looks like it asked for TLS.

Rate limiting before PHP wakes

[[ratelimit]] rules refuse abusive traffic in the same Rust layer that serves cache hits — the login form, an API key, a path glob. The token buckets live in shared memory mapped before the fork, so a limit spans the whole worker fleet instead of each process counting alone: the thing FPM + nginx cannot do without Redis. Refused requests get 429 with Retry-After and the X-RateLimit headers, X-Forwarded-For is believed only from a trusted proxy, and under table pressure the limiter fails open — wrongly refusing real traffic is the worse failure for a web server.

seccomp + Landlock sandbox

--sandbox shrinks the blast radius of a PHP exploit on Linux: seccomp blocks execve/ptrace (no shell), Landlock restricts writes to an allowlist (no webshell into the docroot). It was advisory — a kernel without Landlock or a container missing the seccomp capability logged a warning and served traffic looking exactly like a worker that hardened; --sandbox-required makes that a refusal, exiting 78 rather than serving unprotected, because a security control you cannot tell is off is one you do not have.

Signed releases, verified on upgrade

askr upgrade runs as root and replaces the binary systemd starts, and its trust chain used to end at GitHub: the tarball and its .sha256 came from the same release, so the checksum proved the download arrived intact and nothing about who produced it — a compromised release, account or CI token serves a matching pair. Every tarball is signed with minisign now, and upgrade verifies it against a public key compiled into the binary; a bad signature, a signature from another key, or none at all is a refusal, not a warning, because a warning nobody reads is the old trust chain over again. A build with no key still upgrades and says on every run that it checked a checksum and not a signature, since an unconfigured build must not be one that cannot upgrade. SLSA provenance rides alongside to answer the other question: the signature says the key holder made this, provenance says this workflow built it from this commit. Since 1.5.1 every GitHub Action in that workflow is pinned to a commit SHA rather than a tag a maintainer can move — the one supply-chain path the signature did not cover, because the signing key lives in the workflow and a moved tag would have signed whatever it built with the real key.

Zero-downtime reload, gated

Rolling reload on SIGHUP: workers roll one at a time, each draining in flight. With a canary, one worker rolls first and is judged against the rest of the fleet from per-worker counters in shared memory — error rate and latency relative to the workers still serving, not an absolute count that would charge the canary for everyone else's errors. A failed canary is drained and its slot quarantined rather than left serving a broken build from 1/N of the fleet, and never below one worker. The outcome — rolling, ok, aborted, inconclusive — is readable from /api/status. One caveat, because the alternative is a card that keeps only the good half: on one live deployment a worker was seen still serving the previous release after a reload. It has not been reproduced, the diagnosis reasoned from it has been withdrawn, and the mixed content is unexplained rather than explained (Askr-51). A reload that leaves one worker behind serves the old release from a fraction of requests and reports success, so until it is understood, a deploy that must be certain should recreate rather than reload — and pay the disconnect.

Prometheus + access logs

A Prometheus /metrics endpoint (requests, errors, cache, latency histogram, live workers) plus a structured JSON access log — scrapeable by standard tooling, on top of the live dashboard.

A cache oracle, before you cache anything

askr serve --traffic-log records a line per request that actually ran PHP, and askr cache-report turns an hour of it into the handful of rules an operator would write — hit rate, PHP seconds saved per minute, and a safety verdict per pattern. The verdict is the point: not the hit rate, but whether the same URL ever returned different bytes inside the TTL window. If it did, the page is personalised and caching it would be a bug, so it is left out of the config the report offers you to paste. Set-Cookie disqualifies too. Full-page caching is rare in PHP because of uncertainty rather than performance, and answering it needs the server and the interpreter in one process — Askr sees every request and every response body.

Page caching with no tag list to maintain

Add askr.cache:300 to a route and the middleware records every Eloquent model the response read, then tags the cached page with them — so $product->save() clears exactly the pages that showed that product, across every worker, immediately. Page caching is rare in Laravel not because switching it on is hard but because keeping the tags right is a job nobody wants, and one forgotten dependency serves stale content. Precision while it is cheap, safety when it is not: a page that read a few models is tagged per instance, one that read many degrades to class tags, and one that touched more classes than an entry can hold is not cached at all. It also declines anything it can tell is not shared — authenticated requests, responses that set a cookie, sessions holding more than their own bookkeeping.

The failures used to be silent. Now it says so

Every fault on this project worth an afternoon has been quiet: a queue nobody consumed, a worker polling the wrong queue name, a mailer under the vendor's variable instead of Laravel's, a scheduler shelling out to a php binary the image does not contain. No exception, no log line — the server held every number needed to know and said nothing. 1.4.11 is four answers to that. A watchdog warns when jobs sit unclaimed for 30 seconds and names the queue, because the aggregate “1 job ready” was true and told you nothing about which one. /api/status reports per-queue pending, delayed, reserved and oldest age. askr doctor --app greps your application for onQueue() and compares it with ASKR_QUEUE, and exits non-zero so it can gate a deploy. And scripts/smoke.sh checks a live deployment for every failure that has shipped — it found a real one the first time it ran: a stylesheet answering 404, invisible in every browser because they all had it cached under immutable, max-age=1 year. Then 1.4.13 found one that got past all four: queue workers configured without slots discarded every job, and the watchdog could not help, because it warns about jobs that are waiting and these never got far enough to wait. An empty queue meant either nothing to do or nothing arriving, and those looked identical. A discarded push logs an error naming the queue now — a job that goes nowhere must not be quieter than one that fails. 1.6.0 is the next entry, and the sharpest, because this time Askr knew. A production lane nothing drained for three days was diagnosed correctly every ten seconds — the queue named, the likely cause named — into its own stderr, while the app's /up answered 200 and no failed job, admin warning or health signal said a word. A conclusion that reaches only the log is not a warning. GET /api/status carries a warnings array now, empty when nothing is wrong, each entry with a stable kind and the numbers that justify it, and the watchdog, the API and the Prometheus gauge share one computation so they cannot disagree — a dashboard no longer reimplements Askr's thresholds to rediscover a conclusion Askr already reached, and then drifts from them. It sharpened the question on the way: polls and drains are counted separately, because jobs waiting with nothing polling the lane (a queue-name mismatch, where adding workers does nothing) and workers polling while the backlog still grows look identical in job age and have opposite remedies. A lane that has never been polled reports no timestamp rather than 0, because 0 reads as just now — the exact opposite of the truth. And 1.7.0 is the footnote 1.6.0 earned. The warning system built to name the fault classified this one as queue_not_draining and advised raising the worker count — advice that is not merely wrong but unachievable, since no number of workers on the wrong application can pop those jobs. The classifier matched lanes to jobs by display name, and both applications' lanes display as mail; it matches on the namespaced identity now, and queue_wrong_application names both — whose jobs are waiting, and whose workers are polling the name instead. The admin API had been stripping the namespace too, which is exactly why a queue that could never drain was indistinguishable from one that was busy: every queue entry carries app now, and so does every Prometheus series. The new gauge is deliberately separate from the old one, because nobody is listening and somebody is listening under the wrong application have different fixes, and an alert that conflates them sends an operator after a queue name that is already correct. This card is a record of what has been made audible, not a claim that nothing is left.

Driven over real HTTP, and it was not always

CI starts the binary against a PHP app and asserts over HTTP: cache HIT/MISS/PASS, PURGE and BAN, ESI assembly, fleet-wide rate limiting, source and dotfile disclosure, cache survival across a restart, virtual hosts and all three canary verdicts. Worth saying plainly that this arrived in 1.3.0 — until then CI ran 46 unit tests and never sent a single request, so everything shipped in 1.0.1 through 1.2.0 was verified by hand and nothing stopped it regressing. The suite found a real bug within minutes of existing: cache persistence silently did nothing with a single worker, because the dump lived in a supervisor that a one-worker install never starts. Now 89 tests, plus a weekly advisory scan over the lockfile.

Memory-safe in Rust

A memory-safe Rust hot path serves every request; all unsafe code is confined to the PHP FFI boundary.

shell
# Is your app actually worker-safe?
$ askr serve --paranoid \
    --worker-script examples/laravel-worker.php
✓ no state bleed detected across 500 requests

# Recycle workers + auto-respawn on crash
$ askr serve --max-requests 1000 --workers 8
worker 3 recycled after 1000 reqs · respawned
Boot once, serve many

Kill the bootstrap,
keep the safety.

Worker mode boots your Laravel app once and serves thousands of requests against the warm instance, with per-request state reset so nothing bleeds between requests. --paranoid proves it.

  • Per-request state reset — no bleed between requests
  • --paranoid state-bleed detector tells you if you are worker-safe
  • Graceful recycling (--max-requests) + auto-respawn on crash
  • CoW template forks warm workers for ~ms respawn
Worker mode guide
Up in minutes

One tarball,
serve in production.

Grab a self-contained release for Linux (x86_64 or arm64) — binary, embedded PHP, opcache and examples in one tarball. Then serve your app with TLS and worker mode.

shell
# Download a self-contained release
$ VER=v1.7.4; ARCH=$(uname -m)
$ curl -fsSLO https://github.com/kwhorne/askr/\
    releases/download/$VER/askr-${VER#v}-linux-$ARCH.tar.gz
$ tar xzf askr-${VER#v}-linux-$ARCH.tar.gz

# Pre-flight, then serve with worker mode + TLS
$ ./askr-run.sh doctor
$ ./askr-run.sh serve --root /var/www/app/public \
    --worker-script examples/laravel-worker.php \
    --workers "$(nproc)" --tls-self-signed
✓ serving on :443 · HTTP/2 · 8 workers
Colophon — Get Askr

Your app, warm.
Your server, in Rust.

Download Askr and serve real Laravel in-process — ~9× faster than the FPM model, verified correct under load. Free, open source, single binary.

Askr · v1.7.4 Built in Norway · MMXXVI