v1.4.14 · 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.4.14

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

Running queue workers, TLS, or Laravel in worker mode? Upgrade to 1.4.14 — and 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.4.14: 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.4
$ docker pull ghcr.io/kwhorne/askr:1.4.14   # exact
$ docker pull ghcr.io/kwhorne/askr:latest

# Layer your app onto the Askr runtime
FROM ghcr.io/kwhorne/askr:1.4 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. 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. 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).

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. 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.4.14; 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.4.14 Built in Norway · MMXXVI