Feature Ideas 2026 10 03 - crazy-goat/php-fpm-ng GitHub Wiki
Input: five research angles (competitors, product inventory, operators/SRE, HTTP front, developer adoption), merged and deduplicated by the product lead. Doubtful claims were checked against main at 8dd3838 and against the full issue list (342 issues). Main stays stability first. Nothing here changes the v0.13.0-v0.15.0 milestones. New work starts at v0.16.0, except for a few tiny and safe items that are marked as such.
Verified in code for this page:
-
fpm_http_docroot_real()cachesrealpath(docroot)once per gateway process (sapi/fpmng/fpm/fpm_http.c:2083-2096,static int done).DOCUMENT_ROOT/SCRIPT_FILENAMEuse the unresolvedgw->docroot(fpm_http.c:818-820). The code shows the symlink-deploy mismatch. It has not been reproduced at runtime yet. - The gateway child sets SIGTERM/SIGQUIT to
SIG_DFL(fpm_http.c:3377-3380).fpm_http_cleanup()sends it SIGTERM on every cleanup, including reload exec (fpm_http.c:3800-3808). The gateway listen fd isFD_CLOEXEC(fpm_http.c:3692). -
fpm_pctl_exec()runs cleanups and thenexecvp()with no config pre-check (fpm_process_ctl.c:83-111). - The gateway access log is a fixed Combined format plus
target=(fpm_http_access_log.c:178). - The operator pages have no listen-queue,
max_children_reachedor slow-request series (no match infpm_operator_*.c). - Nothing in
sapi/fpmng/fpmhandlesX-Accel-Redirect/X-Sendfile, PROXY protocol,X-Request-Id/traceparentorNOTIFY_SOCKET. -
examples/status/fpm-ng.conf:28still haspool.type = status.docker/Dockerfile.scratch:2copies the retiredphp-fpm-ng-full. Five of six example Dockerfiles useFROM ubuntu:24.04. -
docs/http-direct.md:1140-1150lists no compression, no Range and no cache policy as deliberate limits. - The gateway refuses
Upgradewith 501 (fpm_http_client.c:138-174). -
packaging/deb/php-fpm-ng.serviceusesType=execandExecReload=/bin/kill -USR2 $MAINPIDwith no-t.
-
"nginx + php-fpm + supervisord + cron for one VPS, as one supported binary, drop-in for an existing php-fpm config." The project owns this niche. It should not try to be an app-server platform (RoadRunner), an async runtime (Swoole, which belongs on branch
async), or an HTTP/2/3 edge server (Caddy/FrankenPHP). - Operations come before new protocol features. The biggest gaps against nginx+php-fpm are in operations: reload drops gateway requests, a bad config takes the service down on reload, there is no systemd readiness, and logs have no timing. Fixing these makes the existing features trustworthy. That is "stability first" carried on after v0.15.
- Behind a reverse proxy (the main case), the gateway's value is correctness, observability and clean proxy integration. That means request id, timing in logs, readiness, graceful drain, and later PROXY protocol. It does not mean compression or caching.
- For direct exposure (sometimes), add only small, opt-in, bounded nginx features that PHP apps really use. Examples: Cache-Control and precompressed sidecars, single-range 206, X-Accel-Redirect, error pages. Never on-the-fly compression, never a rewrite language.
- Adoption depends on the first ten minutes. Today the examples are broken, there is no official container image, no migration guide and no framework recipe for main. These are cheap docs and CI work with no runtime risk.
- NGINX Unit was archived on 2025-10-08. Its PHP users want one process with static files and routes. A migration page aimed at them costs little and fits the niche.
-
Worker mode stays beta and stays honest. Framework adapters and an Octane-style
handle_requestcome only after the worker executor has a leave-beta measurement (#191). They are not a near-term promise.
Cost: S = days, mostly local; M = 1-2 weeks or touches two subsystems; L = several weeks, lifecycle or long-lived state; XL = research first.
| Rank | Feature | Problem | Cost | Risk | Existing issue | Proposed milestone | Issue |
|---|---|---|---|---|---|---|---|
| 1 | Symlink-deploy-aware docroot in the gateway (re-resolve realpath, optional resolved DOCUMENT_ROOT) |
After a current -> releases/N deploy the gateway serves the old release's static files while PHP runs the new release |
S | Low (re-test the static containment check) | none (related #80, async only) | File as a bug after a runtime repro. Tiny and safe, so v0.14.0 is possible; otherwise v0.16.0 | #638 |
| 2 | Fix examples/ and docker/ for the v0.12 build contract, plus a CI step running php-fpm-ng -t on every shipped config |
New users' first try fails (pool.type = status, ubuntu:24.04 with no libphp8.5, retired static artefact) |
S | None (docs/CI only) | none (#564 fixed one example) | v0.14.0 (docs truth, next to #601). Tiny and safe | #639 |
| 3 | Safe reload: validate the config before killing anything | A typo plus SIGUSR2 takes the whole service down; nginx keeps the old config instead | S (unit ExecReload with -t) + M (master-side pre-check) |
Low (an extra gate before an existing path) | none | v0.16.0 | #640 |
| 4 | Graceful gateway drain on stop/reload (phase 1), listener handoff across exec (phase 2) | Every reload/stop cuts in-flight gateway requests and briefly refuses connections | L | Medium (lifecycle code with a SIGSEGV history: #90, #443) | none (needs #593, #596) | Phase 1 v0.16.0, phase 2 v0.17.0 | #641 (phase 1), #661 (phase 2) |
| 5 | Gateway access log: duration, upstream time, request id; optional combined|json; X-Request-Id generate/propagate |
Slow requests cannot be found and gateway lines cannot be joined with app or proxy logs | S-M | Low (trailing fields, opt-in JSON and id) | none (extends closed #341) | v0.16.0 (after #630) | #642 |
| 6 | Native sd_notify (READY/RELOADING/STOPPING/WATCHDOG) without libsystemd; unit Type=notify
|
systemd reports "started" before pools listen; reload does not wait; no watchdog | S | Low (no-op without NOTIFY_SOCKET) |
none | v0.16.0 | #643 |
| 7 | Saturation metrics for fastcgi and classic pools: listen queue, max_children_reached, slow requests |
SREs and HPA/KEDA cannot see saturation; upstream FPM status and php-fpm_exporter users lose signals | S | Low (read-only scoreboard fields) | none (needs #537, #631) | v0.16.0 | #644 |
| 8 | Getting-started page, README truth pass, migration guide (php-fpm+nginx, NGINX Unit, supervisord+cron), framework recipes on main | No path from install to a working combined config; nothing maps existing configs; README says "POC" | S | None | partly #601, #602 | v0.16.0 (right after #601/#602) | #645 |
| 9 | Readiness path (ready.path): 503 while draining or starting, optional "all targets dead" |
LBs, Docker HEALTHCHECK and Kubernetes cannot tell "gateway up" from "app can serve" | M | Low (opt-in, read-only) | none | v0.17.0 (needs drain phase 1) | #646 |
| 10 | Official container images on ghcr.io built from the released .deb (ubuntu:26.04 first) | The "one binary in a container" pitch has no docker run; users build images themselves |
M | Low runtime, medium release process | none (needs #581) | v0.17.0 | #647 |
| 11 | Static files for direct exposure: Cache-Control by path prefix, precompressed .br/.gz sidecars, single-range 206 |
Hashed assets cannot be cached long, nothing is compressed, video seeking and resumable downloads fail | M (three S pieces) | Low-medium (parser and sidecar paths on the public port) | none (reverses the documented list in docs/http-direct.md:1140) | v0.17.0, if the owner approves | #648 (decision #662) |
| 12 | X-Accel-Redirect / X-Sendfile from an allow-listed http.sendfile_root
|
Protected downloads hold a PHP worker for the whole transfer and buffer in gateway memory | M | Medium (upstream header becomes a filesystem path) | none (needs #596, #533) | v0.17.0 | #649 |
| 13 | Response header directive (http.response_header[...], e.g. HSTS) and preloaded error pages for gateway 404/413/502/503 |
A directly exposed TLS gateway cannot set site-wide security headers or show a maintenance page | S | Low (config-time validation, preloaded bodies) | none | v0.18.0 | #650 |
| 14 | PROXY protocol v1/v2 on the gateway listener, trusted sources only | Behind an L4 LB with gateway TLS, every client looks like the LB (ACL, limits, logs) | M | Medium (pre-auth parser on the accept path) | none (needs #593) | v0.18.0 | #651 |
| 15 | Built-in request-duration histograms per gateway target | No p95/p99 without app-side instrumentation; gateway queueing is invisible | M | Low-medium (hot-path atomics, shm layout) | none (builds on #390) | v0.18.0 | #652 |
| 16 | arm64 .deb/.apk (and multi-arch images) | Cheap ARM VPSes and Apple Silicon Docker users cannot use the packages | M | Low code, medium CI time | none (needs #575) | v0.17.0 | #653 |
| 17 | Make selective reload the default | Every reload restarts supervisor, cron and long-lived worker pools | S (after the fixes) | Medium (changes default behaviour) | #330, #537, #631 | v0.17.0 (one release after the fixes) | #654 (decision #664) |
| 18 |
php-fpm-ng --run-pool <name>: run a cron/supervisor script once with the pool's env/ini/user |
Debugging a cron job means waiting for the tick or running php-cli with different settings | M | Low (separate CLI path, master untouched) | none | v0.18.0 | #655 |
| 19 | Public, reproducible footprint and throughput benchmark (real framework app vs nginx+php-fpm and FrankenPHP classic) | No honest answer to "why not FrankenPHP?" and no regression baseline | M | None to runtime; reputational if cherry-picked | none | v0.17.0 (after images and #602) | #656 |
| 20 | ACME: real LE staging issuance, then ARI (RFC 9773) | ACME was never proven against a real CA; certificate lifetimes are getting shorter | S then M | Low (userland renew.php, beta build) | #254 | Backlog: #254 first, ARI as a follow-up | #254 (commented) |
| 21 | TLS leave-beta pass (adversarial + measurement, per the README bar) | Direct exposure needs the beta TLS package with no promises | M | Low (the pass itself) | none (tier decision #269/#380) | v0.18.0 (after #593, #596, #254) | #657 |
| 22 | Host-based routing in http.route[]
|
Two small sites or subdomains cannot share port 443 without nginx | M | Medium (Host parsing at ingress) | none (needs #534) | v0.19.0+ | #658 |
| 23 | WebSocket Upgrade passthrough to a worker route | WebSocket pools need a second public port with their own TLS | L | Medium-high (long-lived state in the network-facing process) | none (deferred in docs/http-route.md) | v0.19.0+ (beta, opt-in) | #659 |
| 24 | Symfony Runtime / PSR-7 bridge for pool.executor = worker (separate composer package) |
Framework users must hand-write the worker loop | L | No C risk; support load on a beta executor | none (needs #602, #191) | After the worker executor leaves beta | #660 (decision #663) |
Merged or dropped from the input:
- Merged. The "operator /health summary" became part of readiness (rank 9). Request id and the access log became one item (rank 5). Error pages and response headers are one item (rank 13). Range, Cache-Control and sidecars are one static bundle (rank 11). The dev compose template became part of the images (rank 10).
-
Dropped. The config converter tool (a mis-translation is worse than the migration guide; revisit only after rank 8).
-t --reload-plan(wait until selective reload is the default). Request-body spooling to disk (it reverses the task 031 decision, it is L, and #596 comes first). A C-side file watcher (see anti-features). The FrankenPHP-stylehandle_requestspike (XL, needs C changes in the request lifecycle; see open question 5).
Why. Deployer, Envoyer and Capistrano style deploys swap a current symlink. That is the normal deploy for the core audience. The gateway resolves realpath(docroot) once per process (fpm_http.c:2083-2096) and builds static candidates from that cached root (fpm_http_static.c:249-267). PHP gets SCRIPT_FILENAME through the unresolved symlink path (fpm_http.c:818-820). After a deploy without a restart, static assets come from release N-1 while PHP renders release N. Hashed asset names then return 404 or stale CSS/JS. nginx users fix this with $realpath_root.
First version.
- Reproduce it with a phpt: start a gateway on
docroot = .../current, request an asset, swap the symlink atomically (ln -sfnplusmv -T), request again. File the result as a bug issue (no milestone, per the process). The owner decides whether it goes into v0.14.0. - Fix: re-resolve the root once per request, or with a short TTL (one
realpath()on a short path is cheap next toopen()+fstat()). Keep the containment check exactly as it is. - Optional directive
http.docroot_resolve = no|yes(defaultno, which keeps today's FastCGI params).yessends the resolved root asDOCUMENT_ROOT/SCRIPT_FILENAME, so PHP and static files always agree within one request (nginx$realpath_root). This also helps opcache, which keys on the path.
What not to do. No inotify or kqueue watcher on the docroot. Do not change the default FastCGI params without the directive. Do not cache the resolved root across requests without a bound.
Definition of done. A phpt swaps the symlink under a running gateway and sees the new asset without a reload. The traversal and containment phpts pass unchanged. docs/gateway.md has a "symlink deploys" section. CHANGELOG entry.
Issues. #638
Why. Evaluators start from examples/. Today examples/status is refused at startup, five Dockerfiles use ubuntu:24.04 (no libphp8.5-embed), docker/Dockerfile.scratch copies an artefact retired in #424, and examples/README.md describes retired pool types and a php-src build. #564 fixed only one example.
First version. Move every example Dockerfile to the ubuntu:26.04 + released .deb pattern that examples/http-direct-worker-mysql already uses. Rewrite examples/status as an operator.* example on a real pool, or delete it. Delete or rewrite docker/Dockerfile.scratch. Fix examples/README.md. Add one CI step that runs php-fpm-ng -t on every examples/*/*.conf and docker/fpm.conf.
What not to do. Do not bring back a static FROM scratch image (#424; the single-file path is #425). Do not build PHP inside the examples.
Definition of done. Every shipped config passes -t in CI. Every example Dockerfile builds in CI or is clearly marked manual. No retired pool type or artefact name remains in examples/ or docker/ (grep check in lint).
Issues. #639
Why. fpm_pctl_exec() goes straight to execvp() after the cleanups (fpm_process_ctl.c:83-111). If the new config is broken, the new master exits and the service is down. Config management (Ansible, ConfigMaps) reloads automatically, so one typo becomes an outage. nginx keeps the old configuration when the new one is invalid.
First version.
- (a, S) Package units:
ExecReload=/usr/sbin/php-fpm-ng -t --fpm-config ... && /bin/kill -USR2 $MAINPID(in ash -cwrapper, or as anExecReload=list). Document the same for Docker/OpenRC. - (b, M) In the master, on SIGUSR2: fork a child that runs the
-tpath against the same argv, before any cleanup. Exit 0 starts the existing reload sequence. Anything else logs ERROR with the child's diagnostics and keeps the old generation running. - Audit what
-treally checks. Unverified: operator listener collisions (#561), route targets, TLS cert/key and docroot existence may only fail at bind or fork time. Each gap found becomes either a-tcheck or a documented limit.
What not to do. No rollback after exec (the old process image is gone). No new reload API over HTTP. Do not try to close the TOCTOU window (file changed between check and exec); document it.
Definition of done. A phpt writes a broken config, sends SIGUSR2, and sees the master still serving with the old pools plus an ERROR line. A second phpt checks that a valid reload still works. The units ship the -t guard. docs/reload.md lists what -t does not catch.
Issues. #640
Why. Gateway children run with SIG_DFL for SIGTERM/SIGQUIT (fpm_http.c:3377-3380), and the master sends SIGTERM on every cleanup, including reload exec (fpm_http.c:3800-3808). The listen fd is FD_CLOEXEC (fpm_http.c:3692). So every reload and every systemctl stop (even with KillSignal=SIGQUIT, because the master forwards SIGTERM to gateways) cuts in-flight gateway requests: uploads, SSE through the gateway, slow responses. Between exec and the new bind, clients get ECONNREFUSED. Selective reload does not cover gateways. The FastCGI children behind the gateway already drain, so the gateway is now the weakest link.
First version (phase 1, v0.16.0). Drain on stop and reload. The gateway gets a SIGQUIT handler: stop accepting, close idle keep-alive connections, let in-flight requests finish up to a hard deadline (reuse process_control_timeout, so no new directive), then exit. The master sends SIGQUIT instead of SIGTERM and SIGKILLs at the deadline. Add a "gateway" row to docs/shutdown-timeouts.md.
Phase 2 (v0.17.0). Keep the gateway listen fds across exec. Either use the existing FPM_SOCKETS inheritance that fastcgi listeners use, or keep the old gateways draining until the new generation listens (SO_REUSEPORT is already in use). Measure the refused-connection window before and after.
What not to do. No nginx-style binary hot upgrade (old and new binaries side by side). No drain without a hard deadline. Do not start before #593 (client timeouts) and #596 (write timeout), because without them one slowloris client holds the drain until the deadline every time.
Definition of done. A phpt starts a slow request through the gateway, sends SIGUSR2 and checks the request completes with 200. A phpt checks a stuck client is cut at the deadline and the reload still finishes. A load test across a reload shows zero resets and zero refused connections for phase 2, with numbers in NOTES.md. Adversarial review, because this is lifecycle code with a SIGSEGV history.
Issues. #641 (phase 1), #661 (phase 2)
Why. The gateway is the main front, but its log is a fixed Combined line plus target= (fpm_http_access_log.c:178). It has no duration, no upstream time and no correlation id. http-direct already has access.format with %d. Operators debugging the v0.13-v0.14 class of bugs (502/503, slow targets) correlate by timestamp today.
First version.
- Trailing fields
duration_ms=andupstream_ms=(andqueue_ms=when pool_full_policy queues, which matches theX-Fpmng-Queue-Waitheader). Trailing fields keep the #341 rule that positional CLF parsers do not break. -
http.access_format = combined|json. JSON has a fixed, documented key set, not a template language. It keeps the single-write()atomicity rule. -
http.request_id = off|generate|propagate(defaultoff).generatecreates 128-bit hex.propagateaccepts an inboundX-Request-Idonly fromhttp.trusted_proxies, with a strict charset and length check, and otherwise generates one. The id goes to FastCGI asHTTP_X_REQUEST_ID, to HTTP route targets as a header, into the log and onto the response.traceparentis already forwarded as an ordinary header; document that.
What not to do. No nginx log_format clone. No OpenTelemetry SDK in C. Never trust an inbound id from an untrusted peer.
Definition of done. The default log line is byte-identical to today's for existing parsers, apart from the new trailing fields. phpts cover JSON escaping (control characters, quotes), id generation, trusted versus untrusted propagation, and the underscore-header rule (#626). Shipped after #630, so gateway and http-direct share one header path. Documented in docs/gateway.md, and http-direct gets the same request-id token.
Issues. #642
| Anti-feature | Why not |
|---|---|
| HTTP/2 or HTTP/3 in the gateway or http-direct | Spike #69 (#186-#189) measured +5.0%/+16.3% at best, against about 2000 lines and a CVE tail. QUIC conflicts with the fork model (#188). The reverse proxy in front already speaks h2/h3. The flags stay reserved and refused (config.m4). |
| On-the-fly gzip/brotli/zstd of PHP responses | CPU in the single-threaded gateway loop, BREACH over TLS, conflicts with SSE flushes and #596 backpressure, and zstd/br add dependencies. Precompressed sidecars (rank 11) and zlib.output_compression cover the need. |
| Response cache or singleflight in the gateway | Spike #67 (#173-#175) closed it. APCu, Redis or a CDN do it better. |
Rewrite/location language, .htaccess, middleware chains, built-in rate limiting or auth plugins |
This turns a small proxy into nginx and grows the network-facing attack surface. The front controller plus http.route[] prefixes is the deliberate scope. #593's connection cap is the real resource bound; rate limits belong in the app or in fail2ban/CDN. |
| Writable control API (scale, kill, reload or run cron over HTTP), NGINX Unit-style config API | The operator listener is deliberately read-only and loopback. Signals, systemctl and the CLI one-shot (rank 18) are auditable. Unit itself was archived. |
| Built-in OpenTelemetry SDK/OTLP exporter in C | A heavy dependency against the "only distro libphp" promise. Request id, durations and Prometheus metrics plus opentelemetry-php cover it. |
| Mercure-style pub/sub hub, jobs broker, KV, gRPC (RoadRunner platform) | A second product. Supervisor and cron pools plus the app's queue library cover consumers. Pub/sub needs #191, which waits for a real need. |
| File watcher / auto-reload inside the C master | New failure modes in the most critical process for a dev-only need. An external watcher (docker compose watch, entr) plus SIGUSR1/SIGUSR2 is enough. |
| Caddy-style on-demand TLS, DNS-01 with provider APIs, OCSP stapling | Abuse and rate-limit risk on a beta surface, one failure surface per DNS provider, and Let's Encrypt ended OCSP in 2025. |
| Fiber/async executors or their framework recipes on main | They belong on branch async (#373, #601). |
Packages for PHP 8.3/8.4, Sury-based builds, bundled extensions in images, static FROM scratch binaries |
Against #419 (distro libphp 8.5 only) and #424. The single-file path is #425. |
Re-adding an aggregate /metrics across pools |
#278 removed it on purpose. Readiness (rank 9) reports state only, never metrics. |
| Cgroup management or a built-in autoscaler | Duplicates systemd, Kubernetes and pm dynamic. Export saturation signals (rank 7) and let the orchestrator decide. |
| Laravel Octane driver or similar public framework promise before the worker executor leaves beta | It would be a public promise on a beta surface. |
- Docroot symlink bug (rank 1): v0.14.0 or v0.16.0? It is a correctness bug in a supported pool type, small and local. Recommendation: reproduce it first. If confirmed, file it without a milestone and pull it into v0.14.0 next to #596. It is small and the target users hit it on every deploy.
- Reverse the documented "static does not do" list (Range, Cache-Control, precompressed sidecars)? docs/http-direct.md:1140-1150 calls these deliberate. Recommendation: yes, for v0.17.0, all opt-in. Allow a single range only, look up sidecars only inside the same containment check, and never compress on the fly.
- Graceful gateway drain (rank 4) is L and touches lifecycle code. Split it? Recommendation: yes. Phase 1 (drain on stop and reload, hard deadline) is the headline of v0.16.0, after #593/#596. Phase 2 (fd handoff across exec) goes to v0.17.0 with a measured before/after.
-
Publish official container images on ghcr.io? This adds a release step that must never overwrite a published tag. Recommendation: yes in v0.17.0. Start with
ubuntu:26.04only, built from the released .deb, with a-tlstag. Add alpine:edge and arm64 only after #575 makes the release fast enough. -
Worker-mode framework story: userland Symfony Runtime/PSR-7 bridge, or a FrankenPHP-style
fpmng_handle_request()spike in C? Recommendation: neither before the worker executor has a leave-beta measurement (#191). After that, start with the userland bridge (no C risk, no php-src patches, see #592). Open the C spike only if the bridge proves too weak. -
Make selective reload the default once #537 and #631 are fixed? It changes default reload behaviour. Recommendation: fix both in v0.13.0/v0.16.0, keep the opt-in for one more release, and flip the default in v0.17.0 with a CHANGELOG entry and
reload.selective = noas the escape hatch.
Tracked as issues without a milestone (decision needed from the owner): question 1 in #638, question 2 in #662, question 3 in #641/#661, question 4 in #647, question 5 in #663, question 6 in #664.