Pular para o conteúdo principal

WarmPHP

WarmPHP — Boot once. Stay warm.

Build Status Opensource ByJG Install MCP Server GitHub source GitHub license GitHub release

WarmPHP turns your PHP CLI into a web application server.

A pool of persistent workers, structured JSON logs and automatic worker recovery, all using your existing PHP binary.

No FPM, no custom SAPI, no custom extension. Your application boots once and stays warm.

Documentation​

Requirements​

Any PHP 8.1 or newer CLI: a distribution's package or any image, nothing built for WarmPHP. -mode prefork also needs the pcntl and posix extensions; php_memory_kb in the logs needs PHP 8.2. Go only to build it.

Without pcntl or posix, the gateway refuses -mode prefork when it starts and names what is missing. The gateway's tests pass with:

PHPVersionFor -mode prefork
Ubuntu 26.04, apt install php-cli8.5.4nothing more
Fedora 44, dnf install php-cli8.5.12dnf install php-process (posix)
Docker's official php:8.5-cli8.5.12docker-php-ext-install pcntl
byjg/php:8.5-cli, byjg/php:8.5-fpm-nginx8.5.12nothing more; turn Xdebug off with -d xdebug.mode=off

How to install each one: Install PHP.

Install​

One static binary, for Linux and macOS, on amd64 and arm64. It needs no PHP to install: it runs the PHP CLI you point it to.

Linux Packages (Debian/Fedora/Alpine)​

Add the ByJG package repository for your distribution, then:

sudo apt install warmphp # Debian/Ubuntu
sudo dnf install warmphp # Fedora/RHEL
sudo apk add warmphp # Alpine

Alpine splits PHP into small packages: php84 alone runs WarmPHP; add the extensions your application needs beside it (php84-session for sessions, php84-pdo_mysql...).

Homebrew​

On macOS (or Linux) with Homebrew:

brew install byjg/tap/warmphp

Homebrew builds it from source, installing Go only for the build.

From source​

make build # ./warmphp

Any other platform Go supports: make build GOOS=darwin GOARCH=arm64. warmphp -version says which version you have.

Quick start​

New here? docs/getting-started.md covers what most people need: install, run your application or framework, put nginx in front, production settings.

make build
./warmphp -workers 4 examples/hello/app.php
curl 'localhost:8080/?name=joao' # requests_served keeps growing: the app is not rebooted

make build builds a static binary (no cgo), which runs on any Linux, Alpine included. GOOS and GOARCH build it for another platform, e.g. make build GOOS=darwin GOARCH=arm64.

Put any reverse proxy in front of it, e.g. Caddy: reverse_proxy localhost:8080.

More than php -S​

PHP's built-in development server starts your application afresh for each request. WarmPHP keeps it loaded across requests, manages multiple workers, and logs request timing, worker activity and PHP errors.

Modes​

Every mode runs persistent php CLI processes (no threads): a worker serves one request at a time and lives until it is replaced. -mode chooses what the entry file is and how the workers start:

-modeEntry fileHow the workers startWhat stays in memory between requests
worker (default)Runs once and returns a callable, which handles each requestEach one is a php process of its own, which boots the applicationEverything the entry built: container, routes, connections, caches
preforkThe same as workerOne master boots the application once and forks them (Pre-fork)The same as worker; the workers share the master's memory and OPcache
legacyA front controller (index.php), included on every requestAs workerEvery class, function and constant loaded, every require_once file, static properties, $GLOBALS
<?php
// -mode worker and -mode prefork: the entry returns the request handler
$app = buildMyApplication(); // once per worker
return static fn() => $app->handle(); // every request

Coming from FrankenPHP or RoadRunner​

You knowIn WarmPHP
FrankenPHP worker mode, RoadRunner-mode worker: the entry returns a callable instead of looping over frankenphp_handle_request()
PHP-FPM's pre-fork of the children-mode prefork
FrankenPHP classic mode, PHP-FPMNo equivalent. They start each request from nothing; no WarmPHP mode does. -mode legacy looks like them, but its process stays loaded (below)
FrankenPHP's watch-watch (Development)

When to use each mode​

SituationUseWhy
A framework of compat/ (-framework), or an entry that returns a callable-mode workerThe default: boots once per worker, no pcntl needed
The same in production, when memory counts or there are many workers-mode preforkOne boot, shared OPcache and memory (Gluo, 4 workers: 59 MiB prefork vs 108 MiB worker, lower is better; measurement scope)
A PHP without pcntl and posix (Docker's php:*-cli, Fedora without php-process)-mode workerprefork needs them
Development, editing the code-mode worker -watch (or legacy -watch)The next request after a save runs the new code; not with prefork
An application that only has a front controller, and is not rewritten yet-mode legacyRuns it as it is; read the rules below first
An application that must start every request from nothingNot WarmPHP: PHP-FPM or FrankenPHP classicEvery WarmPHP mode keeps the process, and what it loaded, between requests

-mode legacy: a front controller in a persistent process​

-mode legacy includes the entry file again on every request, as a web server runs index.php. It is not a fresh start per request, as PHP-FPM or FrankenPHP's classic mode are: the worker process stays, and so does everything PHP loaded in it.

WhatOn the next request
The entry file itselfRuns again, read again
Files the entry loads with require / includeRun again
Files loaded with require_once / include_onceSkipped: require_once returns true, and an edit to them is not seen
Classes (autoloaded or not), functions, constantsKept, from the first request. Declaring them again fails: "Cannot redeclare"
Static properties, $GLOBALS, singletonsKept: state one request leaves there is seen by the next
Local variables, objects the entry createsRebuilt

So, in a front controller run with -mode legacy:

  • Declare functions and classes in files loaded with require_once (or the autoloader), never in the entry file: the second request fails with "Cannot redeclare".
  • Use require_once for what defines things, require for what must run on every request.
  • Do not keep per-request state in static properties, globals or singletons, or reset it at the start of the entry.
  • define() in the entry warns on the second request ("already defined"): guard it with defined().
  • An entry that calls exit() after each response (Symfony's runtime, CodeIgniter) works, but the worker is replaced every time: nothing stays warm. docs/compatibility.md lists what each framework's public/index.php does in -mode legacy; the frameworks there run in -mode worker through their boot files instead.

examples/legacy is a front controller written for it:

./warmphp -mode legacy examples/legacy/index.php
curl 'localhost:8080/?name=joao'

Development: -watch​

A worker keeps the code it booted with (in -mode legacy, the files it loaded): an edit is not seen until the worker is replaced. -watch replaces them when a file of the application changes:

./warmphp -mode worker -watch -workers 1 public/app.php
  • It watches the folder given by -project-root (or PROJECT_ROOT), otherwise the nearest folder up from the entry with a composer.json. It polls the .php, .env, .json, .yaml/.yml, .ini, .xml and .neon files every 500 ms, and vendor/composer/installed.json for composer install and update.

  • It skips .git, .idea, .vscode, node_modules, vendor and the folders frameworks write to while serving: var, storage, writable, tmp, cache, logs. A write there would replace the workers on every request.

  • After a change, the next request runs the new code: idle workers are replaced at once, a busy one when its request ends, and a worker started before the change is never handed a request (the request waits for the new one to boot).

  • The log says what changed, and when every worker runs the new code (the paths are relative to the watched folder; up to 5 are named, then counted):

    {"level":"INFO","msg":"code changed, replacing the workers","files":2,"changed":"src/Model/User.php, config/dev/config.php"}
    {"level":"INFO","msg":"reloaded: every worker runs the new code","changed":"src/Model/User.php, config/dev/config.php","workers":4,"ms":262.4}

    ms is from the change to the last worker up, so it includes a request that was running then.

  • Code that does not boot (a typo) is retried every second until it does: each try logs PHP's error and a warning, and there is no reloaded until it boots.

  • Not with -mode prefork: the gateway refuses it. For development only: in production a worker is replaced by a deploy, not by a file that changes.

What the worker provides on each request​

$_SERVER (with the environment variables, as in the CLI), $_GET, $_POST (urlencoded and multipart, including a[b] and a[]), $_COOKIE, $_FILES (in PHP's layout), $_REQUEST, and exit() and fatal errors (the request is still answered, then the worker is replaced). All of these are reset between requests.

Output works as in a web SAPI: whatever the application writes is the body, streamed to the gateway in 64 KB chunks, whatever it does with output buffers. Closing them all, as CodeIgniter's app/Config/Events.php does on every request, is fine. The body never goes through PHP's own output layer, because in the CLI the first byte that does marks the headers as sent for the rest of the process (session_start() and the session.* settings then refuse to work). If an application closes every buffer and writes before opening a new one, its output still reaches the client in order, and the worker is replaced after that response.

Web functions work as usual​

The CLI SAPI drops response headers. PHP 8 allows redeclaring a function listed in disable_functions, so the gateway starts every worker with these functions disabled (merged with php.ini's own list) and php/functions.php redefines them in plain PHP:

FunctionBehaviour in the worker
header(), header_remove(), headers_list()Same rules as PHP: $replace, $response_code, HTTP/1.1 404 and Status: lines, Location → 302, new lines rejected
http_response_code()Reads and sets the response status
setcookie(), setrawcookie()Same Set-Cookie lines as PHP, including the options array, SameSite and Partitioned
is_uploaded_file(), move_uploaded_file()Only accept the files uploaded in the current request

They work from any code, including namespaced libraries calling header() unqualified. headers_sent() keeps PHP's own: it is false as long as nothing reaches PHP's output layer. When the application sets no Content-Type, the worker sends default_mimetype like the web SAPI.

Sessions work with the regular session_*() functions. Before each request the worker sets the id from the session cookie. After it, the worker saves the session and sends what session_start() sends in the web SAPI: the cookie for a new or regenerated id, placed before the next header the application sets (where PHP would have sent it), and the cache limiter headers (Last-Modified is not sent). The session name is read when the request starts, so set it at boot or in php.ini, not with session_name() during the request.

What the application must change​

Web SAPIIn the worker
php://inputPhpWorker::input(): it is a stream, not a function, so it cannot be redefined

The application is also responsible for not keeping per-request state in static properties or in objects built at boot.

ByJG RestServer​

RestServer (7.0) runs with its default HttpWriter, cookies included. examples/restserver/app.php hands each request to a new Server with Server::withRequestObject(): a PSR-7 request built from the worker's globals and PhpWorker::input():

(cd examples/restserver && composer install)
./warmphp examples/restserver/app.php
curl 'localhost:8080/hello?name=joao'

Existing frameworks​

Symfony, CakePHP, Laravel, CodeIgniter and Gluo (7.0, through gluo-core's Application) were tested without changing them, through the boot files in compat/, which the binary carries:

./warmphp -framework symfony -project-root /path/to/my-symfony-app

docs/compatibility.md lists what worked, what each framework needs and the leaks found.

Benchmark​

docs/benchmark.md runs the same journey (log in, page through the products, read one, edit it, add one, page again) on each framework, served by nginx with PHP-FPM, by nginx with the gateway (-mode worker and -mode prefork) and by FrankenPHP (classic and worker mode), with OPcache on and off. make benchmark runs it; it takes hours, and its BENCH_* variables select what runs, e.g. make benchmark BENCH_ONLY=gluo BENCH_JOURNEYS=10.

docs/analysis.md has the conclusions: the strengths and weaknesses of each server, and the best choice for each framework.

Options​

FlagDefaultDescription
-listen:8080Address to listen on
-phpphpCommand that runs the PHP CLI; may include arguments
-workerembeddedPath to a worker script to use instead of the embedded one
-frameworkRuns the embedded entry of this framework, instead of an entry file: cakephp, codeigniter, gluo, laravel-fresh, laravel-shared or symfony
-project-rootThe application's folder, given to PHP as PROJECT_ROOT; overrides that variable
-extractWrites the embedded PHP files to this folder, which must be empty, and exits
-modeworkerworker, prefork or legacy (Modes); -framework runs in worker or prefork
-workersnumber of CPUsNumber of PHP processes
-max-requests0Replace a worker after this many requests (0 = never)
-watchoffDevelopment: replace the workers when a file of the application changes (-watch); not with -mode prefork
-timeout30sMaximum time to wait for a worker and process a request (then 504 and the worker is killed)
-boot-timeout30sMaximum time for a worker to boot
-max-body32MiBMaximum request body (413 above it)
-upload-dirsystem temp dirWhere uploaded files and the embedded PHP files are written; PHP must see the same path
-versionPrints the version and exits

Running PHP in Docker works too. Use --init, so PHP is not PID 1 and stops on SIGTERM, and use -i for stdin:

./warmphp \
-php "docker run --rm -i --init -v $PWD:$PWD -v /tmp:/tmp -u $(id -u):$(id -g) php:8.5-cli php" \
examples/hello/app.php

Embedded files​

The binary carries php/ and compat/, so it needs no file beside it. PHP can only run real files, so the gateway writes them to a new folder with a random name inside -upload-dir, puts back any that are missing before it starts a PHP process (a /tmp cleaner may delete them from a gateway that runs for weeks), and removes the folder when it stops. A gateway that is killed leaves it behind.

-worker and an entry file take the place of the embedded ones, and -extract writes them out to be changed. After changing php/ or compat/ in this repository, run make build or pass the files.

Logs​

The gateway logs JSON on stderr, one record per line: its own events, a record per request, and what PHP writes to its stderr. Errors, warnings and error_log() become records of their own, with the stack trace in one record and the level taken from PHP's prefix:

{"time":"2026-11-02T16:57:38.77Z","level":"INFO","msg":"request","request_id":"8526147b","method":"GET","path":"/products","status":200,"duration_ms":24.6,"queue_ms":0.1,"bytes":812,"worker_pid":28,"php_ms":23.9,"php_cpu_ms":6.2,"php_memory_kb":2840,"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}
{"time":"2026-11-02T16:57:39.36Z","level":"ERROR","msg":"PHP Fatal error: Uncaught RuntimeException: payment failed in /app/src/Pay.php:42","source":"php","request_id":"a426d85e","worker_pid":28,"stack":"Stack trace:\n#0 /app/src/Checkout.php(18): Pay->charge()\n#1 {main}"}
FieldMeaning
pathWithout the query string, which often carries tokens and personal data
duration_msElapsed milliseconds for the whole request in the gateway, including queue time; lower is better for the same workload
queue_msMilliseconds waiting for a free worker; lower is better. Sustained growth suggests worker saturation; investigate slow requests and capacity
php_msElapsed milliseconds in PHP; lower is better for the same workload. A timeout or killed worker has none of the php_* fields
php_cpu_msCPU time in milliseconds; lower means less CPU work for the same request. php_ms far above it suggests waiting, often for the database
php_memory_kbIncremental peak PHP heap in KiB (1,024 bytes) despite the _kb name; above the request-start heap, excluding OPcache. Lower is better for the same workload; needs PHP 8.2
bytesResponse body size in bytes; neither higher nor lower is inherently better
trace_idFrom the traceparent header, when the request has one; PHP sees that header too

Heap peak is not total process or container memory, and does not measure retained growth between requests. Compare it only across equivalent requests. One MiB is 1,024 KiB (1,048,576 bytes).

  • request_id ties PHP's lines to the request. A worker of its own has its own stderr, so they are always tied. With -mode prefork all the workers share one, so a line gets the id only when one request is running: under load many have none. It is never another request's id.
  • Only what reaches stderr is captured. An application that logs to a file keeps doing so; Laravel logs to stderr with LOG_CHANNEL=stderr. Errors a framework catches go to its logger.
  • Records are written in the background, so a destination that stalls or goes away never reaches the requests or PHP. While 8,192 records wait, new ones are dropped, and a logs dropped record says how many (count) when writing resumes. A stop waits up to 2 s to write what is queued; kill -9 or a crash loses it.
  • A record costs time on the simplest endpoints: see the benchmark's note.

Sending the logs somewhere​

See Sending logs to a backend for Fluent Bit, Docker and OpenTelemetry Collector configurations.

Pre-fork​

With -mode worker, every worker is a php process of its own: it boots the application, and the CLI gives it its own OPcache. With -mode prefork, one master process boots the application and forks the workers with pcntl_fork(), as PHP-FPM forks its children: they share the master's OPcache, and its memory until they write to it. In the benchmark, with OPcache on and four PHP workers, Gluo's end-of-run container memory (including nginx) falls from 108 MiB in worker mode to 59 MiB in prefork mode, about 45 % less. This is not a peak measurement.

  • Each forked worker connects to a Unix socket the gateway listens on, in -upload-dir (PHP must see it), and speaks the same protocol there. It waits on it for its next request with no time limit, whatever default_socket_timeout says; the application's own sockets keep that setting.
  • A forked worker that ended while idle is only seen to end when the gateway writes the next request to it: PHP never got that request, so another worker answers it.
  • The master replaces a worker that exits (exit(), a fatal error, -max-requests) with a new fork of the booted application, so a replacement needs no boot. A worker past -timeout is killed by the master, which the gateway asks on the master's stdin.
  • Connections opened during the boot (database, cache) would be shared by every worker: open them lazily, on the first request. Each worker reseeds mt_rand().
  • If the master itself exits, the gateway stops: restart it.

Protocol​

See the worker protocol reference for startup, framing and request correlation.

Environment variables​

VariableRead byDefault
PROJECT_ROOTthe entries of compat/the framework application's folder, when -project-root is not given (see Existing frameworks)
WARMPHP_PHPmake testthe Makefile's Docker commandthe PHP command of the tests; it must pass stdin through
WARMPHP_COMPOSERmake testthe Makefile's Docker commandthe Composer command that installs the RestServer example's dependencies
COMPAT_DIRthe compatibility tests../gluo-framework-comparisonthe comparison repository
COMPAT_ONLYthe compatibility testsonly the builds whose name contains it
COMPAT_GLUO_STARTERthe compatibility testsa project created from the Gluo starter, for the gluo-starter case
WARMPHP_IMAGEthe compatibility testsbyjg/php:8.5-cli-loadthe PHP image the workers run in

The benchmark's variables are in docs/benchmark.md.

Tests​

The tests run real PHP workers:

make test

WARMPHP_PHP sets the PHP command, which must pass stdin through. The Makefile's default runs byjg/php:8.5-fpm-nginx in Docker, with Xdebug off (the image loads it):

make test WARMPHP_PHP="php" # PHP installed on the machine

The tests also run the RestServer example: make test installs its dependencies with WARMPHP_COMPOSER the first run, and again when its composer.json changes.


Open source ByJG