PHP on Fil-C: memory-safe PHP

10 min read Original article ↗

PHP 8.4.27 · Fil-C 0.686 · also 8.5.12 (with a checked JIT) and 7.4.33 · November 2026

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. It also sets the PHP of the web apps that choose their own (services.mediawiki, services.dokuwiki, services.nextcloud). A pool whose module hard-codes some other PHP keeps it, and evaluation warns that it is not Fil-C; give that pool, or any pool that needs extra extensions, its own phpPackage from pkgsFilc.php.withExtensions …. 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 VM tests run against this module: Nixpkgs' own php-fpm test, with native nginx in front; one of ours that serves GET and POST requests through Fil-C lighttpd and Fil-C PHP-FPM; and Nixpkgs' tests of WordPress, MediaWiki, DokuWiki and Nextcloud. 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:

Zend allocator (ordinary PHP): one 2 MiB chunk a freed value can be read, a neighbour overwritten — all “in bounds” System allocator (this build's default): one object per value the same accesses trap, at the first bad byte

Why the allocator choice is the whole ballgame: Fil-C can only bound what is actually a separate object.

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 JIT by default. Fil-C cannot run machine code it didn't check, so opcache's ordinary JIT and PCRE's JIT are off in the default builds. There is now an opt-in PHP 8.5 whose JIT goes through Fil-C's own compiler instead — see the next section.

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.

A JIT that Fil-C checks

Fil-C's compiler can now run in-process: a program hands it LLVM IR and gets back a function, compiled through the same KillUB and FilPizlonator passes as any other Fil-C code. php85-jit plugs opcache's JIT into it. Opcache still decides what is hot and builds its IR as usual; instead of emitting x86 for that IR, the backend builds an LLVM module in memory and gives it to the Fil-C JIT. So the generated code is bounds-checked and garbage-collected like the interpreter around it, and a JIT bug produces a wrong answer or a Fil-C panic naming the PHP function (jit:TRACE-12$Class::method$34), not memory corruption. Nothing is written to disk and no compiler process runs. Side traces link to each other, as in native PHP's tracing JIT.

nix build github:mbrock/filnix#php85-jit
result/bin/php -d opcache.enable_cli=1 -d opcache.jit=tracing \
  -d opcache.jit_buffer_size=64M script.php

It isn't in the binary cache yet, so the first build compiles the staging toolchain. PHP_JIT_BACKEND=c selects the older backend, which prints C and runs the Fil-C compiler on it.

Zend/bench.php, served warm (seconds) native, JITFil-C, LLVM JITnative, interpreterFil-C, C JITFil-C, interpreter 0.0410.133 — 4.7× the interpreter0.1510.3720.620 WordPress, one pass of 11 requests, steady state (ms) native, JITnative, interpreterFil-C, LLVM JITFil-C, interpreterFil-C, C JIT 2763311654 — 0.87×19202179

Loops over numbers get much faster; page rendering barely moves, because its time is in the runtime, not in opcodes.

It runs real applications. We served WordPress, MediaWiki and DokuWiki through it and compared every response with the interpreter's — status, size and body, with numbers, hashes and nonces masked — and checked the logs. Every response matched and every log stayed empty, with the tracing JIT on either backend and with opcache.jit=1235. That comparison found two real JIT bugs on the way (truncated WordPress pages with status 200; a MediaWiki crash), both fixed in both backends. Opcache's JIT test suite passes 488/488 in function, tracing and 1235 modes.

What it buys. Steady state, after warm-up, against the Fil-C interpreter:

WorkloadFil-C interpreterFil-C LLVM JITNativeNative JIT
Zend/bench.php0.620 s0.133 s (4.7× faster)0.151 s0.041 s
nbody0.358 s0.065 s (5.5×)0.081 s0.019 s
mandel0.049 s0.004 s0.016 s0.004 s
usort0.503 s0.454 s0.103 s0.109 s
WordPress pass1920 ms1654 ms (0.87×)331 ms276 ms
MediaWiki pass1287 ms1339 ms (1.04×)299 ms282 ms
DokuWiki pass68 ms57 ms (0.84×)22 ms21 ms

So on loops over numbers, memory-safe PHP now matches or beats ordinary PHP without its JIT; recursion and array-heavy code still take up to 2× native's interpreter. Applications gain at most about 16%: their time goes to strings, arrays, PCRE, the allocator and the database — Fil-C's checked runtime, which the JIT doesn't touch. Native PHP's own JIT gains only 4–18% on the same pages.

What it costs. Compiling happens in the request that made the code hot. A fresh WordPress worker spends about two minutes compiling 1330 traces before it settles (MediaWiki 130 s, DokuWiki 13 s), and keeps 350–400 MB more memory than the interpreter — about 750 MB per FPM worker, flat over 1600 requests, so not a leak. There is no shared code cache yet, so every worker pays again. A one-off CLI script loses: bench.php run cold takes 3.7 s with the JIT against 0.72 s without. Use opcache.jit=tracing; 1235 compiles whole functions and took 13 minutes on MediaWiki.

In short: worth trying for long-running workers that compute — queue workers, report generation, parsers, numeric code — and not yet worth it for a typical CMS page. The full measurements include cycle counts, warm-up per app, the C backend and FPM memory.

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?

BuildCommandWhat it is
8.4.27 (default)#pkgsFilc.phpThe current 8.4 security release. Also what the tarball and Docker image ship.
8.5.12#pkgsFilc.php85Opt-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).
8.5.12 + JIT#php85-jit8.5 on the staging toolchain with opcache's JIT compiled through Fil-C's in-process LLVM JIT (above). Opt-in and newer than the rest; not yet in the binary cache.
7.4.33#pkgsFilc.php74, #php74-distEnd-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_25The 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 both JIT backends, the 8.5 and 7.4 builds, upstream's own Projeny port, and every test count; the build recipe is in mbrock/filnix.