PHP on Fil‑C: memory‑safe PHP
PHP 8.4, its bundled extensions, the common PECL ones, and every C library under them — all compiled with Fil-C. An out-of-bounds access or a use after free in the interpreter or an extension stops the worker with a panic instead of corrupting its memory. Your PHP code doesn't change. This page is the short version; the full notes are in the docs.
Try it
Docker — no Nix needed. The layout follows the official php images, so it slots in wherever php:fpm does:
docker run --rm -p 9000:9000 -v $PWD:/var/www/html \
ghcr.io/mbrock/filnix-php:8.4.27 php-fpm # FastCGI on 9000
docker run --rm -it ghcr.io/mbrock/filnix-php:8.4.27 # php -a
Tarball — php-8.4.27-filc-x86_64-linux.tar.xz from the releases. It carries php, php-fpm, php-cgi and phpdbg, its own glibc and runtime, and every library its extensions load; unpack it anywhere on x86-64 Linux. Tested on Debian 12/13, Ubuntu 22.04 and Alpine 3, as root and unprivileged.
Nix — the package is one attribute of the Fil-C package set, and withExtensions works as in Nixpkgs:
nix build github:mbrock/filnix#pkgsFilc.php # PHP 8.4, default extensions
nix build github:mbrock/filnix#php-dist # the tarball above
nix build github:mbrock/filnix#php-docker # the image above
The default extension set loads Nixpkgs' defaults plus apcu, redis, memcached, mongodb, imagick (with a Fil-C ImageMagick and its image libraries), protobuf, yaml, zstd and more — about fifty PECL extensions are ported and run their own test suites in the build; the extension list has the details. The interpreter's own suites pass too: on 8.4.27 the core and standard-library sweep passes 13,345 tests with no unexpected failures, and OPcache passes 864.
On NixOS
Filnix ships a NixOS module, nixosModules.filc, that swaps chosen services for their Fil-C builds and leaves the rest of the system — systemd, glibc, everything else — on the ordinary NixOS builds. For PHP-FPM it is three lines on top of the pool configuration you already have:
# flake.nix
inputs.filnix.url = "github:mbrock/filnix";
# pass filnix.nixosModules.filc into your nixosSystem modules, then:
# configuration.nix
imports = [ filnix.nixosModules.filc ];
filc.phpfpm.enable = true; # pools now run pkgsFilc.php
services.phpfpm.pools.www = {
# user, group, listen, settings… exactly as usual
};
The switch sets services.phpfpm.phpPackage to pkgsFilc.php. A pool that names its own phpPackage keeps it, which is also how you give one pool extra extensions — set its phpPackage to a pkgsFilc.php.withExtensions … build. The same module has a switch for lighttpd (filc.lighttpd.enable) to serve it and for several other daemons, but none for nginx: its worker processes share pointer-containing memory, which Fil-C capabilities don't survive, so nginx in front of a Fil-C PHP-FPM stays the ordinary build; filc.enable = true turns them all on. The Fil-C packages come from filnix's own pinned Nixpkgs and its binary cache, so a rebuild mostly downloads. The NixOS notes list every switch, and two VM tests run against this module: Nixpkgs' own php-fpm test, with native nginx in front, and one of ours that serves GET and POST requests through Fil-C lighttpd and Fil-C PHP-FPM. After each, a check confirms the running services really are the Fil-C builds.
How it differs from ordinary PHP
Three differences matter, and the first is the interesting one.
Each PHP value gets its own bounds. Ordinary PHP allocates request memory from Zend's allocator out of 2 MiB chunks. Fil-C checks accesses against the object a pointer refers to — and with Zend's allocator that object is the whole 2 MiB chunk, so an overflow from one PHP value into its neighbour, or a read of a value freed within the same request, looks perfectly legal. This build therefore defaults to the system allocator (PHP's tracked variant, the one its own AddressSanitizer CI uses, so memory_limit and memory_get_usage() still work). Every value is then its own object, and Fil-C can actually see the bug:
That is not hypothetical. The var_destroy use after free in unserialize() (php-src#21824, not yet fixed in a release) runs silently to completion on this build with USE_ZEND_ALLOC=1 — and stops with a Fil-C panic on the default allocator. The default costs about 2× on allocation-heavy code versus Zend's; USE_TRACKED_ALLOC=0 (plain malloc) is about a third faster but gives up memory_limit.
No native JIT. Fil-C cannot run generated machine code, so opcache's JIT and PCRE's JIT are off. There is an experimental build (#php-jit) whose opcache JIT emits C instead, compiles it with the Fil-C compiler and dlopens the result: CPU-bound benchmarks run 4–60× faster than the interpreter, but each hot function costs a compiler invocation, so it suits small CPU-bound scripts, not applications.
Opcache is per-worker. Capabilities don't cross processes, so opcache maps its memory privately. FPM workers share what the master compiled before forking, but each has its own cache after that, and opcache_reset() affects only the worker that calls it.
And the everyday caveats: native extensions must be Fil-C builds (binary PECL downloads won't load), and PHP runs 1.5–4× slower and uses more memory than an ordinary build.
Does it actually catch real bugs?
PHP 8.4.27 fixed two request-handling memory bugs, CVE-2026-109618 (an OOM unwinding a request mid-construction) and CVE-2026-109619 (request globals surviving into the next request). That gave a clean experiment: run the 235 regression tests PHP shipped with the fixes against the vulnerable version — Fil-C PHP 8.4.25, which filnix keeps for exactly this, marked insecure — and see whether Fil-C notices anything on its own. It stopped two of them dead, at the corrupted accesses the CVEs start from, where native PHP runs on:
filc safety error: cannot read pointer with null object.
pointer: 0x4,<null>
semantic origin:
(session.so) mod_user.c:264:3: ps_validate_sid_user
...
(session.so) session.c:2515:9: zif_session_create_id
filc panic: thwarted a futile attempt to violate memory safety.
On 8.4.27 none of the 235 panics. The honest limits: these are PHP's regression tests, not groomed exploits; one route to CVE-2026-109618 needs mod_php or FrankenPHP, which have no Fil-C build, so it wasn't tested; and a trap is still a crash — an attacker who can trigger one can kill workers. Run patched releases; Fil-C is the net, not the fix. The point is that the net was already there before anyone knew the bugs were.
Which PHP?
| Build | Command | What it is |
|---|---|---|
| 8.4.27 (default) | #pkgsFilc.php | The current 8.4 security release. Also what the tarball and Docker image ship. |
| 8.5.12 | #pkgsFilc.php85 | Opt-in current 8.5, with the same patches and allocator default — and the build all the recent speed work targets (a Fil-C HYBRID VM, patched PCRE2, no quadratic string building). |
| 7.4.33 | #pkgsFilc.php74, #php74-dist | End-of-life since 2022, with PHP's minimal mitigation for the two CVEs above. A mitigation while you migrate to PHP 8, not a supported PHP: the unserialize() UAF panics here, but SQL injection and the rest work exactly as in ordinary 7.4. |
| 8.4.25 | #pkgsFilc.php84_25 | The deliberately vulnerable build used above. Marked insecure; exists to reproduce bugs, not to run. |
A research artifact. These are not php.net builds and come with no support or warranty. Fil-C turns memory corruption into a crash; it does not help with logic bugs, injection, or anything your PHP code does on purpose. Keep upgrading PHP — and if a killed worker is better than a compromised one, and 1.5–4× the CPU is affordable, this may be worth evaluating. The full notes cover the JIT-through-C experiment, the 8.5 and 7.4 builds, upstream's own Projeny port, and every test count; the build recipe is in mbrock/filnix.