PHP-FPM with additional operating modes: HTTP, supervisor, cron and metrics — so that the container image holds one binary and the application code, without nginx, without supervisord and without a system cron. Which parts are supported and which are beta is the table under Support tiers.
New here? docs/guides/getting-started.md goes from the
.deb to a working combined configuration. Coming from something else:
php-fpm + nginx,
NGINX Unit,
supervisord + cron. Symfony and Laravel:
docs/guides/framework-recipes.md. One executable
with your own PHAR, php.ini and fpm.conf: docs/guides/single-file-app.md.
Target audience: small projects. One VPS, one instance, typically an application plus one or two consumers plus a few cron jobs. Not k8s.
The argument is not performance — measured a few percent CPU under real load (details in the notes). The argument is one configuration file describing the whole thing and one binary to scan.
sapi/fpmng is a separate SAPI, built against the distribution's PHP 8.5 SDK
(NTS, dynamic, Debian/Ubuntu glibc and Alpine musl; see docs/install.md). The
HTTP gateway on libevent started as a branch in php-src
(https://github.com/s2x/php-src/tree/fpm-http-poc); main carries no php-src
patch. The static musl artefact of the early measurements was retired from
main in #424.
Current state of sapi/fpmng:
- the frontend selects
pool.type = gateway | fastcgi | http-direct; no directive means classicfastcgiand stays compatible with upstream FPM.pool.executoris available onhttp-direct:classicis the default, andworkeris the beta executor used for WebSocket/SSE and other long-lived connections. Issue #388 retiredpool.type = http: the proxy isgateway(itslistenis the public port,http.route[]says what it forwards to) and the PHP workers are an ordinaryfastcgipool behind it. FastCGI pools may opt into per-pool operator metrics on the shared HTTP listener while keeping upstreampm.status_pathon the FastCGI socket (docs/gateway.mdanddocs/operator-endpoint.md). - the
fiberandasyncexecutors moved to branchasync(issue #373) and are not available onmain - metrics:
operator.status_path(JSON) andoperator.metrics_path(Prometheus) expose one pool on an operator listener named byoperator.status_listen/operator.metrics_listen, one target per pool (docs/operator-endpoint.md);operator.metrics/operator.statusare shorthands for/metrics/<pool>//status/<pool>; application metrics from PHP (fpm_metric_register/inc/set/observe, NOTES 3k/3w) through theext/fpmng_metrics/extension, also from CLI viafpm_metric_render() - the
fiberandasyncexecutors live on branchasyncof this repository (issue #373), not onmain
Each tier is defined by the promise it withholds, not by how finished the code feels (decided in issue #269).
| tier | directives | regressions | security reports | may disappear |
|---|---|---|---|---|
| supported | stable within a major release | bugs, fixed with priority | in scope | no |
| beta | may change in a minor release, with a release-note entry | fixed, no response-time commitment | in scope, no response-time commitment | no, but may be redesigned |
| experimental | may change in any release | best effort | best effort, offered as-is | yes, in any release |
Where things stand today (audited against the bar below in issue #380):
| tier | |
|---|---|
pool.type = fastcgi, gateway, supervisor, cron |
supported |
pool.type = http-direct with the default classic executor |
supported |
the operator endpoint (operator.status_path, operator.metrics_path) |
supported |
pool.type = http-direct with pool.executor = worker |
beta |
TLS termination (--enable-fpmng-tls, http.tls_*) |
beta |
ACME certificate issuance (--enable-fpmng-acme) |
beta |
pool.executor = fiber / async |
moved to branch async (issue #373) |
pool.type = http, pool.type = fastcgi-ng and pool.type = status are
retired names, not tiers: http was split into gateway plus an ordinary
fastcgi pool and fastcgi-ng folded into fastcgi (issues #388, #376), and
status became the operator endpoint (issue #278). A configuration that still
names one is refused with its replacement rather than "unknown pool.type".
The worker executor is the only live beta pool type. The #180-#183 spikes named
its exit condition -- the absence of a streaming primitive, not concurrency --
and that was resolved in v0.7.0 (fpmng_worker_respond_start()/_chunk()/_end()
with backpressure, honest metrics, memory recycling, connection info). What is
still missing is the cross-worker wakeup (issue #191, open), so the long-lived
fan-out shape has no measurement under a load resembling use yet; that is the
criterion that keeps it beta, not any open correctness issue.
A pool that is not supported says so in error_log once at startup: a NOTICE
for beta, a WARNING for experimental, naming the pool and the tier. A
supported pool says nothing, so the lines that are there are the ones worth
reading.
Leaving beta takes a PR that flips the tier and states which of these it
claims, with links: tests in CI on every PR covering the failure modes and not
only the happy path; a measurement on real hardware under a load resembling
use, recorded in docs/NOTES.md; no open correctness issue; every directive
documented, including what it refuses and why; and, for anything an
unauthenticated stranger can reach, an adversarial pass and a read by someone
who did not write it. Leaving experimental for beta is the first, third and
fourth of those.
There is a .deb and an .apk that contain no PHP: they depend on the
distribution's libphp (libphp8.5-embed, php85-embed), so a machine needs
no compiler and no php-src to run pool.type = fastcgi, pool.type = gateway or pool.type = http-direct. No pool type needs a patch applied
inside libphp any more (issue #388 retired the last one, pool.type = http),
so the packages run everything this tree ships. Commands, the supported matrix
and what happens on a version mismatch: docs/install.md.
Each release carries two of each (#294): php-fpm-ng, which terminates no
TLS, and php-fpm-ng-tls, the same commit built with --enable-fpmng-tls --enable-fpmng-acme. Both of those are beta by the table above, which is why
they are a package of their own rather than the default one; the two conflict
and either can be installed over the other.
Two behaviour changes in the operator surface. Both fail loudly at startup rather than being ignored, so an affected configuration does not start with a port nobody answers on.
pool.type = statusis gone (#278). A pool that reported on every other pool from a listener of its own is what the operator endpoint (#274) already is. Putoperator.status_pathandoperator.metrics_pathon the pools you want to watch, pointingoperator.status_listenat the address the status pool used. The scraper keeps its port and gains one target per pool instead of one target carrying all of them; the JSON body keeps its{"pools":[…]}shape, one element long. Worked example:docs/operator-endpoint.md.pm.status_listenmeans something else, and is refused onfastcgi(#278). It used to auto-create a second FastCGI pool named<pool>_status; it now names where a type's own operator endpoint binds, and the types with a web server in front of them do not have one. On those, keeppm.status_pathon the pool's own socket and restrict it at that web server, which is where access to a path is already decided.
Since issue #386 the operator endpoint's directives are operator.status_path,
operator.metrics_path, operator.status_listen and operator.metrics_listen,
with operator.status / operator.metrics as shorthands for
/status/<pool> / /metrics/<pool>. The old pm. spellings are refused by
name, not aliased: an affected configuration does not start. pm.status_path
is the one name that stays, and only on pool.type = fastcgi, where it keeps
its upstream meaning. See
docs/operator-endpoint.md.
First run without a configuration file: php-fpm-ng serve
(docs/guides/dev-server.md).
php-fpm-ng is a separate SAPI in sapi/fpmng/, not a fork of php-src:
configure.ac finds directories under sapi/ by glob, so no existing file needs
to be touched. Details, decisions, measured numbers and the list of known
issues: docs/NOTES.md.
pool.type = cron directives (cron.schedule, cron.timezone, cron.log,
...) are documented for operators in docs/cron.md.
pool.type = supervisor restarts a script that exits 0 with no delay, on
purpose — the script sets the pace, and a script that returns instead of
looping now says so in the log. Who decides the interval, and the fast-restart
warning: docs/supervisor.md.
A cron, supervisor, gateway or http-direct pool answers
operator.status_path
and operator.metrics_path on an operator listener of its own rather than on the
socket carrying its traffic — the directives, the default of 127.0.0.1:9253,
the collision rule and what each page contains are in
docs/operator-endpoint.md.
Shutdown grace (process_control_timeout, supervisor.stop_timeout,
cron.timeout, request_terminate_timeout) and what stock defaults do on
docker stop are documented in
docs/shutdown-timeouts.md.
A gateway serves several pools: http.route[<pool>] = <prefix>[,...] sends a
path prefix to a fastcgi or http-direct pool, so an API or a stream
endpoint gets its own workers and its own saturation behaviour without its own
listener. Every route is explicit (issue #388): there is no implicit own-pool
target, a request matching none is a local 404, and at least one route is
required. The longest-prefix rule and why the connection budget belongs to a
target pool rather than to a prefix:
docs/http-route.md.
The HTTP gateway's TLS directives (http.tls_cert, http.tls_reload_check,
...), including how a renewed certificate reaches every gateway process
without a restart, are documented in docs/tls.md. TLS
termination is a build toggle -- FPMNG_TLS=1 ./build/libphp-build.sh, off by
default and only in the php-fpm-ng-tls package (issue #280).
The gateway answers the ACME HTTP-01 challenge itself, on both its own
listen and the plain http.plain_listen companion, from state a cron or
supervisor pool publishes with fpmng_acme_challenge_set() — see
docs/acme-challenge.md. Only one process may
renew a given certificate, and the result reaches every gateway through the
existing no-restart certificate reload —
docs/acme-renewal.md. ACME is a build toggle of its
own on top of the TLS one -- FPMNG_TLS=1 FPMNG_ACME=1, off by default and only
in the php-fpm-ng-tls package (issue #281);
a build without it carries neither the challenge state nor the client, and
refuses an ACME cron.script at startup.
pool.type = http-direct runs HTTP and PHP in the same FPM child, without the
FastCGI gateway hop. It is supported with the default classic executor and
beta with pool.executor = worker (see the tier table). It supports pm = static only, and responses are buffered by default (http.stream = yes
streams them). The FPM master still manages the workers. A pool that
needs routing to several targets, or TLS, uses the gateway in front instead.
Configuration, limits, and benchmark methodology:
docs/http-direct.md.
main ships the classic executor, which runs one request at a time per worker
like upstream FPM, and the beta worker executor on pool.type = http-direct,
for long-lived connections: one worker holds several requests at once. See
docs/frameworks.md. CI runs a Slim 4 smoke test on gateway+fastcgi and on
http-direct classic (tests/frameworks/).
The framework measurements for pool.executor = fiber (Symfony, Laravel, Slim 4)
belong to the fiber executor and live on branch async
(async/docs/frameworks-fiber.md).
Gain with no line of code, measured on the test box (details: docs/NOTES.md, 3m and 3t):
listen = /run/php/pool.sock ; UDS instead of TCP loopback: -7..-11 us/req
php_admin_value[max_execution_time] = 0 ; no setitimer per request: -3 us/req
catch_workers_output = no ; logs via error_log()/stderr to our own stack
request_cpu_tracking = no ; if nobody reads "last request cpu" or %Crequest_terminate_timeout still guards wall-clock time, so
max_execution_time = 0 does not leave a request without a guard.
main builds against the distribution's prebuilt PHP 8.5 SDK (NTS, Linux,
dynamic). It needs no php-src tree and does not compile PHP:
# Debian / Ubuntu 26.04
apt-get install build-essential libevent-dev libacl1-dev \
php8.5-dev libphp8.5-embed php8.5-cli
# Alpine: build-base libevent-dev acl-dev php85-dev php85-embed php85
./build/libphp-build.sh out # out/php-fpm-ng, out/commands.log
FPMNG_TLS=1 FPMNG_ACME=1 ./build/libphp-build.sh out # needs libssl-devThe platform matrix, the packages and the known limitations of this build are
in docs/install.md. The test suites run against this binary:
./build/run-fpmng-phpt.sh - out-results (see docs/fpmng-phpt.md). macOS is a
from-source development platform only (build/prepare.sh against a php-src
tree).
Work is tracked in GitHub Issues, not in the tree. gh issue list shows
what is open; labels carry type (bug, enhancement, spike, refactor,
decision, ...), area (area:http-direct, area:tls, area:fiber, ...) and
priority. Everything under track:nice-to-have applies only to
pool.executor = fiber, which is not on main (branch async).
Before touching anything, read AGENTS.md: the English-only
rule, the architecture contract (new behaviour in new files under
sapi/fpmng/fpm/, never strcmp(type->name, ...)), the evidence rule, the
shared test box, and the build, lint and test commands. The step-by-step
process from issue to merged PR is docs/workflow.md, and
releases follow docs/release-workflow.md. What a
comment in this codebase is for — and which comments must never be deleted
without re-establishing the fact first — is
AGENTS.md.
Until 2026-09-08 work was tracked as one Markdown file per task under tasks/.
That directory is gone; docs/task-archive.md maps
every task NNN reference still in the comments and commit messages to either
its issue or the git command that prints the original file.
MIT, Copyright (c) 2026 Crazy Goat Software, see LICENSE, except for the code taken from or derived from php-src:
- PHP License 3.01:
third_party/php-src/(vendored subset)build/phpt-fixture-patches/sapi/fpmng/config.m4andsapi/fpmng/Makefile.fragsapi/fpmng/fpm/fpm.c,fpm_children.c,fpm_conf.c,fpm_conf.h,fpm_process_ctl.c,fpm_request.c,fpm_request.h,fpm_stdio.candzlog.h(modified copies ofsapi/fpm/files)
- BSD-2-Clause, Copyright (c) 2007-2009 Andrei Nigmatulin (original FPM code, text in
third_party/php-src/sapi/fpm/LICENSE): thesapi/fpmng/fpm/copies listed above.
Those files carry no license header of their own, mostly only the original "(c) 2007,2008 Andrei Nigmatulin" line; this list is what states their license. The same list is in LICENSE.
The .deb and .apk packages ship all three texts in one file: /usr/share/doc/<pkg>/copyright on
Debian and /usr/share/licenses/<pkg>/LICENSE on Alpine (assembled by build/package-licenses.sh).