Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Huskarl AI colleagues on your own Mac Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra
Upgrading Askr

Upgrading Askr

The short version: within 1.x, an upgrade is a drop-in. Replace the binary (or the image tag), reload, done. You don't need to touch askr.toml.

That's a promise, not a hope — the surfaces that make it true are listed in STABILITY.md, and new features arrive as new config keys that default to off, so a config written for 1.0.0 still means exactly the same thing on the newest 1.x.

One release has broken it, deliberately. 1.7.0 refuses to start an instance that uses [[site]] together with a queue or scheduler sidecar until you say which application the sidecar serves. The alternative was to keep guessing, and the guess had been silently throwing away every queued job on those instances since 1.5.1. If that is you, see To 1.7.0 — it is one line of config, and worth reading before you upgrade.

How to upgrade

Release tarball (systemd install)

askr upgrade                 # downloads, verifies signature + sha256, swaps the prefix
sudo systemctl reload askr   # graceful; see below

askr upgrade replaces the whole prefix (binary + bundled libphp) atomically and keeps the previous version at <prefix>/../askr.old. It does not restart the server unless you pass --restart.

From 1.5.0 it verifies a minisign signature against a public key compiled into the binary, and refuses a release it cannot verify — a missing signature included. The .sha256 is still checked, for what it is: proof the download arrived intact, not proof of who produced it.

Verify it yourself if you'd rather not trust the updater:

VER=v1.9.5; ARCH=$(uname -m)
BASE=https://github.com/kwhorne/askr/releases/download/$VER
TARBALL=askr-${VER#v}-linux-$ARCH.tar.gz

curl -fLO $BASE/$TARBALL
curl -fLO $BASE/$TARBALL.minisig
curl -fsSL https://raw.githubusercontent.com/kwhorne/askr/$VER/keys/release.pub -o askr.pub
minisign -V -p askr.pub -m $TARBALL

# And the provenance attestation, which binds it to the workflow and commit that built it
gh attestation verify $TARBALL --repo kwhorne/askr

Docker

docker pull ghcr.io/kwhorne/askr:1.9.5     # or :1.7 to follow patches

Pin the exact version in production and bump it deliberately. :1.9 follows patch releases, :latest follows everything — convenient for a laptop, surprising on a server at 3am.

The -full tags (1.9.5-full) are the same server built with the optional features compiled in: sql-backend, observ, otel, http3. If you use any of those, stay on -full.

The Laravel package

composer update kwhorne/askr-laravel

The package and the server are versioned independently; any recent package works with any 1.x server. Upgrade it when you want a new PHP-side helper.

Zero-downtime upgrades

A reload replaces the workers without dropping a connection:

kill -HUP $(pidof askr)      # or: systemctl reload askr, or POST /api/reload

Workers finish their in-flight requests, then are replaced one at a time — there is always a live worker accepting. A reload does not pick up a new Askr binary: the master process is the old one. For a new binary you need a restart, which means a brief gap unless something in front of you retries.

If you can afford one more moving part, the sturdiest sequence is:

  1. askr upgrade (new binary on disk, old one kept)
  2. askr config-check askr.toml — catches a config that the new version rejects before you stop anything
  3. drain the queue — the shared-memory ring does not survive a restart, and jobs still in it are lost with no error anywhere the application can see (Maintenance)
  4. restart

Turn on the canary so a bad application deploy can't take the fleet with it — worth having in place before you start upgrading things:

[reload]
canary = true
canary_window = 5
canary_min_requests = 20

See Deployment.

Rolling back

  • Tarball: the previous prefix is at <prefix>/../askr.old. Swap it back and restart.
  • Docker: run the previous tag. This is why pinning matters.
  • Config: a config written for an older 1.x is still valid on a newer binary, so upgrading never requires touching askr.toml. The reverse holds from 1.7.4 on: a key the older binary does not know is ignored with a warning, so the file still loads. Rolling back to 1.7.3 or earlier is different — those releases refuse an unknown key, so a key added for a newer release ([queue] root in 1.7.0, say) has to be removed first. Either way, askr config-check askr.toml run against the binary you are about to roll back to tells you in one command, before you stop anything.

Rolling back is a supported operation, not an emergency improvisation. If a downgrade ever fails on a config that the newer version accepted, that's a bug worth reporting — it means we added something that isn't additive.

Version-by-version notes

Nothing here is required. These are the things worth adopting after each upgrade.

To 1.9.5

Nothing to change with examples/laravel-worker.php. A file uploaded without a field name now reaches Laravel under a number, as under PHP-FPM.

With your own worker script: a file in $r['files'] can now have a number as its field. A file whose field name PHP will not repair (foo[]bar) is no longer there, and neither is any file after it in the same request, as under PHP.

To 1.9.4

Nothing to change. In worker mode, PHP's warnings about a multipart body now reach your log, and the page if display_errors and display_startup_errors are on, as under PHP-FPM. They never reach your app's error handler.

If a worker script read $r['body'] for a multipart request with no usable boundary: it is empty now, as php://input is under PHP. The warning says why.

To 1.9.3

Nothing to change. In worker mode, a multipart body that ends early or is malformed part-way now reaches the app instead of being a 400. A file the body ended in has error 3 (UPLOAD_ERR_PARTIAL), so in Laravel isValid() is false and validation fails with "failed to upload", as under PHP-FPM. If you relied on Askr refusing such bodies, your app now sees them, as it would under PHP.

To 1.9.2

Nothing to change with the shipped worker (/opt/askr/examples/laravel-worker.php, or the tarball's): it and the binary come together.

If you copied laravel-worker.php into your app, upgrade Askr first, then the copy. The new worker builds ordinary UploadedFiles instead of test-mode ones, which relies on Askr 1.9.2 telling PHP about the uploads: on 1.9.1 or earlier every upload would fail isValid(). Keeping your old copy on 1.9.2 works as before.

With your own worker script: move_uploaded_file($f['tmp_name'], …) and is_uploaded_file() now work as under PHP-FPM, for the files of the request being handled. If you moved files with rename() to get round them, either still works.

To 1.9.1

With examples/laravel-worker.php: take it, and if you copied the worker into your app, take its new file-nesting block too (the parse_str() over $r['files']). Without it, $request->input('tags') and $request->input('user') now work in multipart forms, but nested file inputs such as user[avatar] still don't reach $request->file('user.avatar').

With your own worker script: $r['post'] is now shaped as PHP shapes $_POST — tags[] is $r['post']['tags'], a list; user[name] is $r['post']['user']['name']; a . or space in a top-level name is _. Code that read the raw names ($r['post']['tags[]']) has to read PHP's shape instead. $r['files'] is unchanged: a flat list with each field as sent.

To 1.9.0

If your app runs in worker mode and accepts uploads, check upload_max_filesize and post_max_size before you take it. Worker mode used to accept any file up to max_body_size (16M by default) whatever php.ini said; it now applies PHP's upload settings as PHP does, and PHP's defaults are small:

Setting PHP's default Over it, the app sees
upload_max_filesize 2M the file with error 1 (UPLOAD_ERR_INI_SIZE) and no content — in Laravel isValid() is false and validation fails with "failed to upload"
post_max_size 8M no fields and no files at all
max_file_uploads 20 the files past it missing

To keep taking what you took before, raise them — the engine's ini lines apply in both modes — and keep max_body_size at least as large:

[server]
max_body_size = "64M"

[worker]
ini = "upload_max_filesize=64M\npost_max_size=64M"

After the upgrade the worker's startup line shows the limits in force (upload_max_filesize=… post_max_size=…), and Askr logs a warning naming the setting whenever one refuses an upload — look for upload: in the log after deploying.

Also in worker mode: a file's type is now what PHP gives — the part's Content-Type up to any ;, and empty when the client sent none (it was application/octet-stream). Laravel's getClientMimeType() then falls back to application/octet-stream itself.

Per-request mode is unchanged: PHP has applied these settings there since 1.8.5.

To 1.8.7

Nothing to change. Stops, reloads and recycles no longer wait 10 seconds per worker for idle keep-alive connections; a request in flight still finishes. Two things you may notice:

  • SSE streams end when their worker drains, and EventSource reconnects on its own, to a worker that is staying. A page that shows connection state from the error event will show a brief reconnect on every deploy.
  • If a deploy script or health check waited a fixed 10+ seconds after a reload because that was how long it took, the wait can come down.

To 1.8.6

If you run your own worker script and it handles uploads, take it — and check that script. $r['files'][..]['name'] is now the file's basename, as PHP's $_FILES gives it; until now it was the filename as the client sent it, so it could be ../../public/shell.php. Code that built a path from it could write outside its upload directory. The name as sent is in the new full_path key: never use it as a path.

With examples/laravel-worker.php, nothing to change. Symfony already stripped the path, so Laravel was not exposed. If you copied the worker into your app, take the one-line change to $f['full_path'] ?? $f['name'] so getClientOriginalPath() keeps a folder upload's path; without it, from 1.8.6 that returns only the basename.

To 1.8.5

In per-request mode (no [worker] script), check your upload limits before you take it. Uploads in that mode arrived empty from 0.4.0 until now; they now reach PHP, and PHP handles them as PHP-FPM does — so PHP's own limits apply, and PHP's defaults are upload_max_filesize = 2M and post_max_size = 8M. A larger file arrives in $_FILES with error 1 (UPLOAD_ERR_INI_SIZE); a larger body leaves $_POST and $_FILES empty. Raise them with the engine's ini lines, which apply in both modes despite the section's name:

[worker]
ini = "upload_max_filesize=64M\npost_max_size=64M"

(or --ini, or ASKR_PHP_INI). Keep [server] max_body_size at least as large; Askr answers 413 above it before PHP sees the request.

Worker mode is unchanged. Its uploads are parsed by Askr, which does not apply those PHP settings; where it differs from PHP is listed in scripts/phpt-uploads.known.

To 1.8.4

Nothing to change — but take it if Askr speaks TLS (a certificate, --tls-self-signed or --acme). It updates rustls and the certificate verification and root store under it, with four security advisories; see the changelog.

To 1.8.3

Nothing to change — but take it. The embedded PHP is 8.5.12 (from 8.5.8), with security fixes in OpenSSL's hostname verification, the HTTP stream wrapper, mysqlnd, Phar, BCMath and GD; see the changelog. The release tarballs and images carry it; askr doctor reports embedded PHP 8.5.12.

If you build from source, rebuild libphp: PROFILE=laravel ./scripts/build-libphp.sh. It now verifies the PHP tarball's SHA-256 before unpacking it and stops on a mismatch.

To 1.8.2

Nothing to change. Dependency updates only; see the changelog for what each was checked against. askr upgrade verifies this release's signature with the new minisign-verify, which was tested against a real release signature before it shipped.

To 1.8.1

Nothing to change. In the Docker image askr is now on the PATH (docker exec <container> askr why /). On a host, askr-run.sh can be linked into your PATH as askr; with earlier releases such a link made the launcher run itself in a loop, so remove any workaround you had for that.

To 1.8.0

Nothing to change. Everything new is a command you run or a key you add:

  • askr why <url> and askr top — both need the admin plane ([admin] listen), and askr why sends a real GET through the server (it runs PHP and can fill the cache).
  • [worker] paranoid_sample = 1000 — state-bleed detection in production; findings in /api/status under state_bleed. Needs a worker script that loads the detector, as examples/laravel-worker.php does.
  • [reload] verify = true (with canary = true) — the canary replays recent anonymous GETs before the rest of the fleet rolls. Worth turning on if you reload in worker mode; see Deployment for what it can and cannot see.

Two costs to know. Every response is now counted per route for askr top — measured at about 1% on a hello-world PHP route and nothing on static files. And with verify on, each anonymous GET that runs PHP has its body hashed for the replay to compare.

To 1.7.6

Nothing to change. Two fixes, both in how workers forked during a reload behave: every worker now drains on SIGTERM, so a reload rolls the whole fleet and a stop finishes; and /api/status no longer hangs after a reload. If you worked around either — restarting instead of reloading, or a stop timeout that ended in SIGKILL — a reload is enough now.

New, optional: app_id ([server] app_id, [[site]] app_id). Leave it unset and nothing moves. Set it only if you use the durable SQL backends (ASKR_CACHE_DB, ASKR_QUEUE_DB) and want applications kept apart in them, and drain the queue first if the application already uses ASKR_QUEUE_DB: its queue names gain a prefix, and jobs queued under the bare name are not seen afterwards. See Storage backends.

To 1.7.5

A docroot behind a symlink now follows the symlink. If root (or a [[site]] root) goes through a link that your deploys swap — /srv/app/current/public — a reload now serves the new release. Before, the root was resolved to the release it pointed at when Askr started, and every reload served that release again; see Deployment. If you worked around that by restarting instead of reloading, a reload is enough now.

Two things move with it, both only for a root that goes through a symlink:

  • DOCUMENT_ROOT and SCRIPT_FILENAME show the path as configured (/srv/app/current/public/index.php) rather than the release directory — what nginx's $document_root gives. Laravel takes its paths from __DIR__, which PHP resolves itself, so it does not notice.
  • The application's shared-memory namespace changes once, because it is now derived from the path as written. Everything in shared memory is rebuilt on a restart anyway, with one exception: a persisted queue ring ([queue] persist). Jobs in it were pushed under the old namespace and would not be seen by the upgraded workers, so drain the queue before this upgrade, as Maintenance already asks for any upgrade. A saved response cache ([cache] persist) simply starts cold.

A root with no symlink in it keeps its namespace exactly — the hash is the same one, now written out so that a future Rust toolchain cannot change it either.

To 1.7.4

An unknown key in askr.toml is now a warning, not an error. The server starts, the key is ignored, and the log names it with its location and the key it was probably meant to be:

unknown config key `max_workers` in [queue] is ignored (a typo, or a key from a newer
Askr than this one) — did you mean `workers_max`?

Nothing changes for a config that loads today. What changes is what a mistake costs: a typo no longer stops the server, but it also no longer takes effect, so a deploy that used to fail loudly now starts without the setting. Keep askr config-check in the deploy and read its output — it prints the same warnings, before resolving the rest of the file.

Also new: a warning when applications share the SQL backends (sql-backend builds only). See STORAGE_BACKEND.md.

To 1.7.3

Nothing to do — take it for the TLS fix. rustls 0.23.45 closes RUSTSEC-2026-0285 (medium): TLS 1.3 handshake messages were accepted at the wrong encryption level. The handshake itself stays authenticated, so this is not a way into a connection, but a dependency audit of 1.7.2 will flag it and 1.7.3 will not.

One behaviour tightens, and a serving process never reaches it: with no application set, a process can no longer acknowledge or release another application's queue job by its lease. Every request and every sidecar has an application, so if anything you run notices this, PHP was executing outside both — worth a report.

To 1.7.2

Nothing to do for a single application. The fix is for instances hosting several ([[site]]): askr_cache_flush() — Laravel's Cache::flush() and artisan cache:clear — now empties only the calling application's cached pages, where it used to empty every application's.

If [cache] persist is set, the response cache starts cold once. Entries gained a field recording their application, so the saved dump changes format and one written by an older Askr is refused rather than read with the wrong layout. It refills as pages are requested.

A cache flush with no application set is now refused instead of emptying everything. A serving process always has one, so you should never see the log line that says so (askr_cache_flush() was called with no application set, and refused). If you do, PHP ran outside a request or a sidecar, and that is worth a report.

Not changed, and worth knowing if you run the L2 SQL cache (sql-backend, ASKR_CACHE_DB) with [[site]]: L2 is not namespaced, so applications in one instance share it. That is a design decision still open — see the changelog.

To 1.7.1

Behind a reverse proxy with trusted_proxies set, REMOTE_ADDR is now the client instead of the proxy. That is the fix, and for most applications it is simply what they expected all along. Two things can change underneath you, and both are worth checking before you roll it out.

Forwarding headers from an untrusted peer are now removed. If you never set trusted_proxies and instead let Laravel's TrustProxies read X-Forwarded-For itself, that header no longer reaches PHP, and $request->ip() becomes the proxy. Askr logs this the first time it happens (X-Forwarded-For arrived from a peer that is not in [server] trusted_proxies). Move the trust to Askr:

[server]
trusted_proxies = ["172.18.0.1"]   # what TrustProxies used to list

A TrustProxies list naming your proxy stops matching. Symfony checks that list against REMOTE_ADDR, which is now the client, so it stops honouring X-Forwarded-Proto and X-Forwarded-Host. Scheme is still right if Askr has https = true; host is still right if your proxy passes Host (proxy_set_header Host $host;). The clean fix is to set TrustProxies to '*' — safe here, because Askr has already removed every forwarding header that did not come from a trusted proxy and collapsed X-Forwarded-For to one value. Full reasoning in Behind a reverse proxy.

Standalone instances with nothing in front, and nothing in trusted_proxies, see no difference except that a visitor's own X-Forwarded-* headers no longer reach PHP.

To 1.7.0

Read this one before upgrading if you use [[site]]. Two things can bite, and one of them may already have.

Your queue may have been dead since 1.5.1. Shared memory is namespaced per application, derived from the docroot. [[site]] makes each virtual host a separate application — but queue and scheduler sidecars took the namespace of the top-level [server] root, and askr_queue_pop matches the namespaced key. A sidecar rooted at one application cannot see another's jobs at all. Jobs were accepted, stored, and never read: no exception, no failed job, nothing the application could see. One deployment ran that way for six days.

Check before you upgrade, on whatever version you are on. Enqueue a job and watch reserved go above zero:

curl -s -H "Authorization: Bearer $ASKR_ADMIN_TOKEN" http://127.0.0.1:9000/api/status \
  | jq '.queues'

reserved: 0 on a lane with pending above zero, holding steady, is the signature. An empty queue proves nothing, and neither does an absence of backlog warnings straight after a restart — a restart recreates the ring, so the warnings are guaranteed to be quiet for a while whether or not anything is wrong.

A [[site]] instance with a sidecar now refuses to start until you say which application the sidecar serves:

[queue]
root = "/var/www/example.com/public"   # = the [[site]] root that dispatches the jobs
slots = 8192
workers = 4
script = "/opt/askr/examples/askr-queue.php"

[scheduler]
root = "/var/www/example.com/public"   # defaults to [queue] root
script = "/opt/askr/examples/askr-scheduler.php"

If the top-level application is the one queueing, set [queue] root to the same path as [server] root. That is a valid answer — it just has to be an answer, because Askr cannot infer it from a queue script and the previous default was a guess that failed in silence.

Single-application instances need nothing. No [[site]], no change: both keys default to [server] root, which is already what you want.

If you alert on queue metrics, every per-queue series now carries an app label alongside queue, so a recording rule or dashboard that groups by queue alone will now see one series per application instead of one merged series. There is also a new gauge worth an alert of its own:

# Jobs one application queued that only another application's workers poll.
# Adding workers cannot fix this — the queue name is right and the application is not.
max by (queue, app) (askr_queue_unreachable) == 1

And /api/status gained app on every queue entry, plus a queue_wrong_application warning kind carrying polled_by. Switch on kind, never on detail.

To 1.6.1

Nothing to do. A dependency refresh and two test fixes; no behaviour changed, no config key added, nothing to adopt. Take it for the refreshed TLS stack (rustls 0.23.44, aws-lc-rs 1.18.1) if you terminate TLS in Askr, or skip it and take 1.6.2 when there is one.

To 1.6.0

Nothing is required, and one thing is worth wiring up the same day. GET /api/status now carries a top-level warnings array — empty when all is well, and populated with a stable kind, the queue name and the numbers behind it when a queue is not being drained. Render it somewhere a person looks. That is the whole point of the release: Askr has been able to diagnose an abandoned queue for several versions and could only say so in its log, where a production site missed it for three days.

Switch on kind (queue_unattended, queue_not_draining), never on detail — the prose is not stable, and is written for a human reading a dashboard.

If you scrape Prometheus, the new per-queue series are worth an alert:

# A lane with jobs that nothing is polling. The remedy is the queue *name*, not more workers.
max by (queue) (askr_queue_unattended) == 1

Note that askr_queue_seconds_since_poll and askr_queue_seconds_since_drain are absent for a lane where it has never happened rather than 0 — 0 would read as "just now" — so alert on askr_queue_unattended or use absent(), not on a comparison that a missing series silently never satisfies.

If your queues are deliberately batchy, [queue] stall_secs (default 30) is now the one knob behind the log line, the warnings array and askr_queue_unattended. Raise it rather than learning to ignore the warning.

Docker users: check your --admin bind. docs/DOCKER.md used to open with --admin 0.0.0.0:9000, which since 1.5.1 refuses to start without ASKR_ADMIN_TOKEN — and which, even with a token, puts the reload trigger on the Docker network. The documented pattern is --admin 127.0.0.1:9000: the image's healthcheck runs inside the container and reaches it, and the port leaves the network entirely. If you copied the old quick-start, this is the change to make. Nothing in the binary changed here; the documentation was wrong.

The job ring's layout version changed, so [queue] persist rings written by an older Askr are recreated empty at boot, with a log line saying why. Pending jobs in a persistent ring do not survive this one upgrade. Drain the queue before upgrading if that matters; persist is off by default, so most deployments are unaffected.

To 1.5.2

If you are on 1.5.1, this upgrade is not optional. 1.5.1 answered 400 to every HTTP/1.x request to a Laravel or Symfony app, because HTTP_HOST reached PHP as works.example, works.example:443 and Symfony rejects a comma in the host. HTTP/2 was unaffected, so a site behind an h2 proxy — or an HTTP client pinned to 2.0 — may never have seen it. Nothing to configure: upgrade and remove any ['version' => 2.0] you added to work around it.

One value changes, and only over HTTP/2. HTTP_HOST is now the authority exactly as the client sent it, port included — which is what nginx + FPM pass, and what PHP already saw over HTTP/1.x. Over HTTP/2 it previously arrived port-stripped, so an h2 request to a non-default port now shows example.test:8443 where it showed example.test. This is the correct form and the two protocols now agree, but if you compare HTTP_HOST against a literal, or feed it somewhere that expects no port, use SERVER_NAME — that is still the port-stripped form, and it is what virtual-host routing and the response-cache key use. In Laravel, $request->getHost() strips the port for you; getHttpHost() keeps it, as it should.

To 1.5.1

One change can stop a start. A non-loopback admin bind now requires ASKR_ADMIN_TOKEN; without it the server refuses to start rather than exposing an open reload trigger and a public dump of PIDs and memory. If you run --admin 0.0.0.0:… (or any non-loopback [admin] listen) with no token, set one, or bind the admin plane to 127.0.0.1 and reach it over SSH. A loopback bind is unchanged.

One change is visible to code that reads job ids. Queue jobs are now leased: a job's id, as the driver hands it back, changes on each retry, because it identifies the reservation rather than the row — that is what stops a worker whose lease lapsed from acknowledging a job another worker has since taken. If you correlate log lines across a job's attempts, key on the payload's uuid (what Laravel's failed_jobs uses), not the id.

The rest need no action, but are worth knowing:

  • Multi-site instances are partitioned. With [[site]], the shared cache, sessions, locks, counters and job queue are now keyed per application (by docroot), so two sites in one instance no longer share them — two domains on one docroot still do. Queue and scheduler sidecars belong to the application at the top-level root; a second application in the same instance has no workers of its own. Broadcasting stays instance-wide (one Pusher secret per instance). See Hosting.
  • Vary responses are cached again. In 1.5.0 a response carrying its own Vary (e.g. Accept-Language) was not cached; it is now stored as one variant per value, so the hit rate returns on localised pages without serving one visitor's language to another.
  • The Pusher HTTP trigger requires a signature. POST /apps/{id}/events must carry Pusher's auth_signature (and body_md5, auth_timestamp). pusher-php-server — and therefore Laravel's broadcaster — sends it on every call, so a correctly configured app needs no change; a bespoke trigger caller must sign, or it gets a 401.

New, opt-in: [queue] persist = "<name>" keeps the job ring in a named shared-memory object so pending jobs survive a restart (askr upgrade included). In a container raise --shm-size; see Docker.

To 1.5.0

Nothing is required. Two things are worth adopting deliberately, because upgrading alone will not turn them on:

  • --sandbox-required / [server] sandbox_required. The sandbox used to warn and serve unhardened when a kernel feature was missing, which looks identical to success. Required mode refuses to serve instead. It needs sandbox_write, and refuses to start without it — seccomp alone does not stop PHP writing a webshell, because Askr interprets PHP in-process and no process creation is involved. See Sandbox.
  • ASKR_ADMIN_TOKEN, if you run behind a local reverse proxy. PURGE/BAN used to be accepted from any loopback peer with no token — and behind nginx or Caddy on 127.0.0.1, every request is a loopback peer. Setting trusted_proxies now makes the token mandatory for those methods, so if you have trusted_proxies configured and no token, cache invalidation will start answering 403. That is the fix working; set the token.

Three behaviour changes to be aware of, none of them configurable:

  • A response carrying its own Vary is no longer cached. The cache key cannot express an arbitrary Vary, and the header used to be dropped rather than honoured — so a localised app answering Vary: Accept-Language had one visitor's language served to everyone. Correctness costs hit rate on exactly those responses.
  • Scheme is part of the cache key. With force_https off, http and https no longer share an entry. Expect a one-time dip in hit rate.
  • Underscored header names are dropped. X_Forwarded_For no longer becomes HTTP_X_FORWARDED_FOR in $_SERVER, because it collided with the dashed spelling and bypassed anything filtering it. This is the same default nginx ships. If an app of yours genuinely reads an underscored header, rename it to use dashes.

Releases are now signed, and askr upgrade refuses one whose signature does not verify against the key compiled into the binary. Upgrading to 1.5.0 from an older build still uses the old checksum-only path — the verification lives in the new binary, so it protects the upgrade after this one.

To 1.4.14

Nothing to do. If you use askr doctor --app, two things behave better:

  • It now resolves configuration the way the application does — real environment variables beat .env — and names the source of each value. Run it inside the container for this to be meaningful.
  • Its scheduler check no longer fires on Artisan::command(), which defines a command rather than scheduling one.

To 1.4.13

If you run queue workers, make sure [queue] slots is set — 8192 is a reasonable start. Askr now refuses to start without it when queue workers are configured, so a misconfiguration is an error at boot rather than mail that quietly never sends.

[queue]
slots = 8192          # required alongside workers
workers = 4
script = "/opt/askr/examples/askr-queue.php"

The equivalent on the command line: --queue-script now requires --queue-slots.

To 1.4.12

Take this one if your app draws QR codes — Laravel Fortify's two-factor setup, most likely. Before it, iconv was not compiled in, and the page answered 500 with Call to undefined function iconv(). Nothing to change beyond the version.

If you build from source on macOS, PROFILE=minimal also works again there.

To 1.4.11

Nothing to do; everything is additive. Two things you may now see for the first time:

  • WARN queue backlog is not being consumed in the log, naming a queue. It is telling you the truth: jobs on that queue are not being taken. Either no queue worker is running (--queue with --queue-script) or it doesn't poll that name (ASKR_QUEUE, comma-separated). This was previously invisible.
  • /api/status has a queues array. queue_ready and friends are unchanged.

Worth running once against a running deployment:

askr doctor --app /var/www/example.com        # from inside the container, ideally
./scripts/smoke.sh https://example.com http://127.0.0.1:9000 "$ASKR_ADMIN_TOKEN"

Both exit non-zero on a failure, so they can gate a deploy. The first found a real misconfiguration on the deployment it was written against; the second found a real production fault on its first run.

To 1.4.10

Nothing to do — both changes are additive.

If you use auto-TLS and wanted a config file, you can now have both: move the flags into an [acme] section (see CONFIGURATION.md). Note that [acme] and [tls] are mutually exclusive, and that a [acme] section with domains but no enabled = true is now an error rather than a silent no-op.

If you monitor queue depth, delayedSize(), reservedSize() and creationTimeOfOldestPendingJob() now report real numbers instead of 0/null — a threshold that has never fired may start firing, because it was reading zeros.

To 1.4.9

Package-only fix — composer update kwhorne/askr-laravel. On Laravel 13 the queue driver was a fatal error at class-load time (missing contract methods), so anything that resolved the queue — sending mail, dispatching a job — killed the worker and answered 502 with askr: php worker died mid-request. No server upgrade needed.

Note that delayedSize() and reservedSize() report 0 and creationTimeOfOldestPendingJob() reports null on this driver: Askr's queue knows those values internally but doesn't expose them to PHP yet. If you rely on queue:monitor thresholds, use pendingSize()/size(), which are accurate.

To 1.4.8

Upgrade if you run Livewire, Flux or anything needing Alpine in worker mode, and take the worker script with it — the fix is in examples/laravel-worker.php, so a new binary alone changes nothing if you copied the old script into your project:

docker pull ghcr.io/kwhorne/askr:1.4.8
docker run --rm ghcr.io/kwhorne/askr:1.4.8 \
  sh -lc 'cat /opt/askr/examples/laravel-worker.php' > storage/askr-worker.php   # if you keep your own copy

Symptoms this fixes: interactivity that works for the first page load or two and then stops, wire: and x- attributes doing nothing, a Flux appearance toggle showing both icons — all with an empty console, because the script tag was missing rather than broken.

To 1.4.7

Upgrade if you serve HTTPS. Over HTTP/2 — which ALPN picks by default over TLS — Askr lost the host the request was addressed to. Nothing to configure; the effects were:

  • generated URLs and redirects came out as https://localhost/… (Laravel builds them from the request, not from APP_URL);
  • virtual hosts fell through to the default site, so with [[site]] an h2 request for one domain could be served another's app — check your access logs if you host several;
  • response-cache entries could be shared between domains. If you cache and host more than one domain, flush the cache after upgrading: curl -X BAN -H 'X-Ban-Url: /*' … or simply restart without [cache] persist.

To 1.4.6

If you use --config together with other flags, Askr will now refuse to start and tell you which flags it would have ignored. That's the fix: they were never applied. Move them into the config file. Nothing else changes.

Worth reading if you deploy with Docker on Linux: the new bind-mount ownership and behind-nginx sections cover the traps that make a laptop-tested compose file fail on a server.

To 1.4.5

Upgrade — this closes Askr-46. If you serve a Laravel app with Flux/Livewire in worker mode, file responses (flux.js, downloads, streamed exports) were killing workers; that is gone, with the standard asset setup and no workarounds. Also: an exit() or an escaping exception in the app now costs that one request instead of the worker. The docs/WORKER_MODE.md known-issue section is obsolete as of this release.

To 1.4.4

Nothing to do. No behaviour changes for a healthy app — this release is about what happens when one isn't:

  • a failed accept() no longer takes down a worker that is serving other requests;
  • a worker that dies mid-request answers 502 instead of a complete-looking 200 with an empty body (if you have monitoring that only checks status codes, it will start seeing these — they were always failures, just invisible ones);
  • the log says what actually ended a worker instead of guessing "fatal/OOM?". If you have alerts matching that string, they won't fire any more. The replacements name the case: the request channel closing, or the worker script leaving its loop.

To 1.4.3

Upgrade if you run Laravel in worker mode, and take the new worker script with it — the fixes are in examples/laravel-worker.php, so a new binary alone changes nothing if you copied the old script into your project:

docker pull ghcr.io/kwhorne/askr:1.4.3
composer update kwhorne/askr-laravel        # for the cache/queue config fix
# using your own copy of the worker script? re-copy it:
docker run --rm ghcr.io/kwhorne/askr:1.4.3 \
  sh -lc 'cat /opt/askr/examples/laravel-worker.php' > storage/askr-worker.php

What changes for you:

  • Authenticated requests no longer leak between visitors. If you ran worker mode with sessions before 1.4.3, this was happening — quietly, and only on workers that had served a login.
  • HTML form posts work. If you saw unexplained 419s on submit and worked around them with a header or by disabling CSRF for a route, undo that.
  • File downloads and streamed responses have bodies. Anything you thought was a Flux, Livewire or download bug is worth re-testing.
  • CACHE_STORE=askr works without editing config/cache.php. The manual entry the package README documented is now optional; keeping it changes nothing.

To 1.4.2

Two things are worth acting on.

If you set ASKR_ADMIN_TOKEN and run the Docker image, your containers were reporting unhealthy — the healthcheck polled the gated /api/status. Nothing to configure; the image now polls /healthz. If you wrote your own probe, point it at /healthz too.

If you want http:// visitors redirected, you can now have it:

[server]
force_https = true
http_redirect = "0.0.0.0:80"      # not needed with --acme; automatic there

Also worth knowing:

  • The admin plane denies by default. If you drive it with a script that hits some path other than /, /favicon.ico or /healthz, that path now needs the bearer token. The documented endpoints are unchanged.
  • ACME keys are re-written 0600. If your tooling read key.pem as a non-owner user, it will stop. That it worked before was the bug.

To 1.4.1

Upgrade if you serve with the Docker image or the release tarball. Before 1.4.1, a PHP notice, warning or deprecation was written into the HTTP response body — including absolute filesystem paths — and not logged. The published 1.4.0 image served this to anyone requesting the homepage of a stock Laravel 12 app on PHP 8.5:

Deprecated: Constant PDO::MYSQL_ATTR_SSL_CA is deprecated since 8.5 …
in /app/vendor/laravel/framework/config/database.php on line 62

A framework masks this once its own error handler is installed, but config files are parsed before that, so boot-time diagnostics went straight to the client. In worker mode it also preceded the headers and truncated the page.

Nothing to configure — diagnostics now go to the log. Two things to know:

  • If you relied on seeing PHP errors in the browser during development, opt back in: ASKR_PHP_INI="display_errors=1".
  • Check your log volume after upgrading. Notices that were previously discarded (log_errors was off) are now written. If a busy app emits one per request, that's real output — and a good prompt to fix the notice, since it was always there.

error_reporting is unchanged (E_ALL), so nothing new is hidden; it changed destination, not visibility.

To 1.4.0

Worth doing, in this order:

# 1. Find out what's actually worth caching — and what only looks cacheable
askr serve --traffic-log /tmp/traffic.jsonl    # leave it for an hour of normal traffic
askr cache-report /tmp/traffic.jsonl
// 2. Cache the routes it called safe. No tag list to maintain: the page is tagged
//    with the models it read, so a save() clears exactly the pages that showed them.
Route::get('/products/{product}', ProductController::class)
    ->middleware('askr.cache:300');

Requires composer update kwhorne/askr-laravel for the middleware.

One behaviour change to know about. A response carrying more cache tags than an entry can hold (8) is now refused rather than cached. Before, the surplus tags were silently dropped — which meant askr_cache_forget_tag() could never reach them, and the page stayed stale until its TTL expired.

If you hand-write long tag lists you may therefore see a page stop being cached, and askr_cache_tag_overflow_total count up on /metrics. That page was already broken; it just failed quietly. Tag by class (posts) instead of per instance (posts:3), or cache a smaller fragment with ESI.

--traffic-log is a diagnostic: it writes a line per PHP-served request, so turn it off again when you have your answer.

To 1.3.0

No action needed. Internally this is a dependency refresh (including four major bumps) plus a much larger test suite; no user-visible behaviour changed.

Two things to know:

  • A persisted response cache may be dropped once. [cache] persist files are tied to the entry layout, and a new Askr build can invalidate them. The first boot after an upgrade then starts with a cold cache and logs response cache dump ignored (different build or cache size). That's the guard working — a cache is never reinterpreted across layouts.
  • ASKR_*_DB SQLite files are opened by a newer bundled SQLite (rusqlite 0.31 → 0.40). SQLite is backwards compatible with older files, so nothing to do, but take your usual backup first if those hold queue jobs you can't lose.

To 1.2.0

Worth adopting:

# Refuse abusive traffic before PHP wakes up — enforced across the whole fleet
[server]
trusted_proxies = ["10.0.0.0/8"]     # required behind a load balancer

[[ratelimit]]
path = "/login"
limit = 5
window = 300

# Keep the cache across restarts
[cache]
persist = "/var/lib/askr/rcache.bin"
persist_key = "your-release-sha"
  • If you configure rate limits behind a proxy and forget trusted_proxies, every client shares one bucket and you'll rate-limit your whole site. Askr warns at startup; take the warning seriously.
  • Run askr tune --root public for measured starting values for workers and max_rss_mb.
  • The canary gate got much better in this release (it compares the new worker against the rest of the fleet instead of an absolute error count). If you'd tried canary before and found it aborted deploys for no reason, try it again.

To 1.1.0

Worth adopting: ESI if a single live widget is what keeps a page uncacheable, PURGE/BAN for URL-targeted invalidation, and [[cache.rule]] if you need cache policy for an app you can't edit.

PURGE/BAN are gated on ASKR_ADMIN_TOKEN, or restricted to loopback when no token is set. Set the token if you want to invalidate from a deploy script on another host.

To 1.0.1

Upgrade if you're on 1.0.0. Static file serving could disclose PHP sources and dotfiles: GET /index.php returned source, and with a document root pointed at an app root, GET /.env returned APP_KEY and database credentials. Fixed in 1.0.1, with the suffixed variants (index.php.bak, config.php~) fixed in 1.1.0.

While you're there, check that your document root is a dedicated public/ directory and not the application root.

From 0.9.x to 1.0

1.0 added no features — it froze the surface. If your 0.9.12 setup worked, 1.0 works identically.

One deprecation carried over: --acme-directory became --acme-directory-url in 0.9.7 (it was too easily confused with --acme-dir, the local certificate cache). The old spelling still works as a hidden alias.

What can actually bite you

Honest list, in rough order of how often it happens:

  1. A cold cache after a restart. The response cache lives in shared memory. Unless [cache] persist is set — and the dump is still valid — the first requests after any restart hit PHP. Coalescing stops it becoming a stampede, but a big site restarting at peak will feel it. Reload rather than restart when you can.
  2. A config the new version rejects. Validation gets stricter as it gets better (glob patterns that look like regexes, rules with no effect). This is deliberate — a silently ignored rule is worse — but it means askr config-check askr.toml belongs in your deploy script, before the restart. Unknown keys are the exception: they warn rather than refuse, so that check is also where you see them.
  3. libphp and the binary are a matched pair. A release tarball contains both. Don't mix a new askr with an old bundled libphp; askr upgrade and the Docker images handle this for you.
  4. A PHP security release means a new Askr release. PHP is compiled into the distribution, so you can't patch it independently. Watch PHP's releases as well as ours.
  5. Optional features live in the -full build. Upgrading from a -full tag to a plain one silently drops sql-backend, observ, otel and http3. The server will start and your ASKR_OBSERV_DSN will simply do nothing.

After upgrading

askr doctor                    # PHP build, extensions, platform probes
askr config-check askr.toml    # config still valid and resolving as you expect
curl -s localhost:9000/api/status   # workers alive, version, rollout state

If something looks wrong, the admin API and the observability guide are the fastest ways to see what the server thinks is happening.