Table of Contents

PHP RFC: Single binary for CLI and FPM

Introduction

Today, the php and php-fpm executables contain the same engine and the same extensions. Only a small part of the code (the CLI and FPM SAPIs themselves) differs. Distributions that need both ship that code twice, and when FPM starts right after a CLI command, it loads that code a second time from a different file.

This RFC proposes to link the CLI SAPI into the php-fpm executable, and to make php-fpm run as the CLI when it is invoked under the name php. One binary can then run both the CLI and FPM:

php-fpm --nodaemonize -y /etc/php-fpm.conf # FPM, as today
 
ln -s php-fpm php
./php script.php                           # CLI

The php executable is not changed. Distributions that keep shipping two executables are not affected. Those that want a single one can install the php-fpm executable as php, with php-fpm as a symlink to it, so that all the PHP code is shipped once and loaded once.

Motivation

A single executable brings two separate benefits: smaller PHP distributions, and a faster FPM start when FPM follows a CLI command. Both were measured with Bref, which runs PHP on AWS Lambda, by comparing two builds of its PHP runtime (called “layer” in Lambda):

Setup: PHP 8.5.11, Linux x86-64, Bref 3.0.12, October 2026.

Smaller distributions

Two executables Single executable
php 25,985,096 bytes 25,986,576 bytes
php-fpm 25,985,744 bytes symlink
Layer, zipped 24.3 MB 19.9 MB (-18%)
Layer, unzipped 89.9 MB 64.5 MB (-28%)

The combined executable is barely larger than either separate one: the second SAPI adds about 140 KB of code and data (+0.6%), which fits in the padding the linker adds to align segments. So the second executable's 26 MB are saved almost entirely.

This matters wherever space is limited and both SAPIs are needed. For example, Lambda caps a function and its layers at 250 MB unzipped, and container images, small devices and self-contained (statically linked) PHP distributions all ship both executables today.

Faster FPM start after a CLI command

On every cold start, Bref's runtime for web applications runs PHP twice: the CLI executes Bref's bootstrap script, which then starts FPM to serve requests. With two executables, FPM's start has to load its own copy of the engine, even though the bootstrap has just loaded the same code from php. With a single executable, php-fpm is the same file, already loaded.

Cold start durations, median of 50 cold starts per function, 1024 MB of memory, eu-west-3 region, with the 95% confidence interval of the difference:

Application Two executables Single executable Difference
Minimal application, FPM 280 ms 211 ms -70 ms (-25%) [-79, -59]
Minimal application, without FPM 195 ms 193 ms no difference [-6, +6]

Starting faster is critical on Lambda, but it helps any platform that starts PHP on demand to absorb load: autoscaled containers, scale-to-zero services, serverless platforms. The same pattern happens there whenever a CLI command runs right before FPM starts, for example a container entry point that warms a cache or runs migrations. How much it saves depends on how costly it is to read the executable: some platforms load container images lazily like Lambda does, while on a host where the image is already on a local disk, the gain will be smaller. These measurements only cover Lambda.

Proposal

Build

When both the CLI and FPM SAPIs are built, the CLI SAPI is also linked into the php-fpm executable. There is no new configure option:

Configuration Result
CLI and FPM enabled A php executable, unchanged, and a php-fpm executable that can also run the CLI
FPM enabled, CLI disabled (--disable-cli) A php-fpm executable, unchanged
FPM disabled (the default) Not affected

An option would only be useful if linking the CLI into php-fpm had a cost. It has none that we found: the CLI has no library dependencies of its own, so php-fpm only grows by the CLI code (about 150 KB in a minimal build), and its behavior under its own name is unchanged.

make install installs php and php-fpm as two separate executables, as today.

Running the CLI

php-fpm runs as the CLI when it is invoked under the name php, optionally followed by a digit, for example through a symlink (or a hard link, or a copy). All arguments are the CLI's usual arguments. Under any other name, it is FPM, unchanged:

php-fpm -v                                # PHP 8.7.0 (fpm-fcgi)
php-fpm8.7 --nodaemonize                  # FPM
 
ln -s php-fpm php
php -v                                    # PHP 8.7.0 (cli)
php -S localhost:8000                     # the built-in web server
ln -s php-fpm php8.7
php8.7 script.php                         # CLI

The rule covers the names used by distributions for the CLI (for example php8.3 on Debian or php83 on Alpine), while leaving out other programs whose name starts with php: php-cgi, php-config, php-fpm, phpdbg, phpize. A name such as php-8.7 (from --program-suffix=-8.7) is not recognized, because it can't be told apart from those programs.

In CLI mode, the executable behaves exactly like the php executable: PHP_SAPI is cli, it reads the same php-cli.ini and accepts the same options. The whole CLI test suite (sapi/cli/tests) passes when run through a php symlink to php-fpm. There is no way to switch from one SAPI to the other in a running process.

Shipping a single executable

PHP_BINARY is the real path of the executable: symlinks are resolved. Scripts that run PHP_BINARY to start another PHP process, such as Composer restarting itself or PHPUnit's process isolation, expect it to run the CLI. A distribution that ships a single executable should therefore install it as php, with php-fpm as a symlink to it:

/usr/bin/php      # the php-fpm executable
/usr/sbin/php-fpm # symlink to /usr/bin/php

With this layout:

The opposite layout, php as a symlink to php-fpm, works for the CLI itself, but PHP_BINARY is then php-fpm, and scripts that run it would start FPM. This is documented in UPGRADING.

Backward incompatible changes

None for builds that don't include both SAPIs.

When both SAPIs are built:

Internal changes, listed in UPGRADING.INTERNALS: when the CLI is built, PHP_FPM_OBJS includes PHP_CLI_SHARED_OBJS, and PHP_FPM_WITH_CLI is defined.

Proposed PHP version

PHP 8.7.

RFC impact

To SAPIs

To existing extensions

None. Extensions are the same in both modes, and extensions that check the SAPI name keep seeing cli or fpm-fcgi.

To packaging

Nothing changes unless distributors choose to:

A combined executable uses one set of build options and extensions for both modes. Distributions that build the CLI and FPM with different configure options, in separate builds, can keep doing so.

Alternatives considered

Linking FPM into the CLI

The first version of this RFC proposed the opposite: a --enable-cli-fpm configure option that linked FPM into the php executable, which then ran FPM with php --fpm or when invoked under a php-fpm name (PR #23558). It was changed following the discussion on internals:

Selecting the CLI with an option

php-fpm could run the CLI with an option, for example php-fpm --cli script.php. It is not proposed: FPM's argument parsing stays untouched (some FPM and CLI options use the same letters, such as -F), the CLI receives its arguments unchanged, and a single executable needs to be installed as php for PHP_BINARY anyway.

A shared library

PHP could build the engine as a shared library (libphp.so) and make php and php-fpm small executables linked to it, as PHP does on Windows with php8.dll. Both executables would keep their names, and the shared code would be on disk once.

This RFC doesn't follow that approach:

A shared library would also avoid loading the engine twice, since both executables would map the same file. The two approaches are not exclusive.

Open issues

None.

Future scope

Voting choices

Primary vote, requiring a 2/3 majority:

Patches and tests

Implementation

To be completed after the vote.

References

Rejected features

Changelog