Blog 23 min read
Real PHP on an ESP32: welcome to the php-baremetal blog
The unmodified Zend engine from php.net, cross-compiled for a microcontroller: what php-baremetal is, what it runs today, and the new WiFi support.
Gianfrancesco Aurecchia
@GianfriAur
There is a five-dollar chip on my desk with an LED wired to one of its pins, and the thing making that LED blink is index.php. Not a script that talks to the board over a serial cable from a laptop — the PHP interpreter itself, compiled for the chip's own CPU, running on the metal.
Another one, on the shelf behind it, has no cable attached at all. It creates its own WiFi network, serves a web page from PHP over that network, and changes the colour of its onboard LED when you drag a slider on your phone.
This is the first post on this blog, and it lands with the 1.0.0 release — so it doubles as the announcement. Rather than announce something narrow I want to walk through the whole thing: what php-baremetal is, how it actually works inside the chip, what it can already do, and where the honest limits are.
TL;DR
php-baremetal cross-compiles the unmodified Zend engine from php.net for microcontrollers and runs your index.php on the chip, through PHP's official embed SAPI. Nothing about PHP was re-implemented — the port is eight to ten small build-time patches, depending on the version. It runs today on the ESP32-P4 and ESP32-S3, as an Arduino-style setup()/loop() sketch or as an HTTP server, with OPcache, OpenSSL, SQLite (PDO or the SQLite3 class), Composer and WiFi driven straight from PHP. This post ships with the 1.0.0 release.
New in 1.0.0
1.0 is a consolidation release — the foundations, made solid enough to call it 1.0:
- A stable, typed API across every built-in extension (argument and return types, and a
<name>_available()probe on each), plus a newsysextension for timing, reboot, chip identity and memory introspection. - Your choice of SQLite API per project: PDO, or the
SQLite3class. - A CI that checks every version's manifest and builds across boards and project types — and boots the image under QEMU to catch a boot regression without hardware.
- All of it verified on real hardware.
Not a subset, not a transpiler
Previous attempts to put PHP on a microcontroller have generally been approximations: a PHP-like language, a subset of the syntax, a transpiler that turns your code into C. Those are reasonable engineering choices, and they tend to share the same failure mode — the moment your script does something slightly unusual the illusion breaks, and you find yourself debugging the reimplementation rather than your program.
php-baremetal takes the other road. The PHP source tree is vendored unchanged, sha256-verified, and never edited in place. What makes it build for a bare-metal RTOS is a small set of patches applied at build time, each addressing a single portability point. Here is the current set for the default version, 8.4.25:
| Patch | What it addresses |
|---|---|
0001-closure-runtime-cache-arena |
Closure runtime-cache arena handling |
0002-ext-date-optional-minimal-tz |
Makes ext/date optional, with a minimal timezone footprint |
0003-mbstring-optional-no-cjk |
Makes mbstring optional and drops the CJK tables |
0004-csprng-esp-getrandom |
Backs the CSPRNG with the ESP hardware RNG |
0005-session-files-no-cloexec-warn |
Silences an O_CLOEXEC warning from the files session handler |
0006-opcache-static-embed |
Lets OPcache link statically into the embed build |
0007-opcache-malloc-shm-backend |
A malloc-based SHM backend so the in-RAM OPcache lives in PSRAM |
0008-zend-portability-sigsetjmp-guard |
Guards sigsetjmp where POSIX signals are unavailable |
0009-phpinfo-baremetal-section |
Adds the board and firmware info table to phpinfo() |
The set is per version: 8.3.33 needs eight of these, 8.4.25 nine, and 8.5.9 ten — the extra one drops a lexbor dependency that 8.5 introduced in ext/uri. That is the entire delta. Everything else — the engine, the VM, the bundled extensions — is stock PHP, which is also why a version bump is mostly a matter of regenerating those patches against a new tree. The jump from 8.4.24 to 8.4.25 needed no changes at all: all eight applicable patches applied unchanged and the source file set was identical.

The engine is hosted through the embed SAPI — the minimal library interface PHP ships specifically for running the engine inside another C program, the same mechanism that has always existed alongside the CLI, FPM and Apache SAPIs. php_embed_init() brings the engine up; the firmware supplies the output sink, the request hooks and the environment around it. And it is native code, not emulation: PHP's C is built with riscv32-esp-elf for the P4 or xtensa-esp32s3-elf for the S3, and the opcodes execute on the board's own CPU.
Which means: hand the same script to this engine or to a desktop php binary and you get the same output, because underneath it is the same interpreter. Namespaces, classes, traits, enums, closures, generators, match, exceptions, typed properties, attributes, Reflection, SPL, PCRE, JSON, the CSPRNG — they work because nobody re-wrote them.
Here is a complete php-baremetal program. Not an excerpt — the entire thing.
<?php
// runs once at boot
function setup(): void
{
gpio_mode(2, GPIO_OUTPUT);
}
// runs forever, like an Arduino loop
function loop(): void
{
gpio_write(2, 1); sys_delay(500);
gpio_write(2, 0); sys_delay(500);
}
If you have written an Arduino sketch the shape is familiar: setup() once at boot, loop() forever after. The difference is that this is PHP: gpio_mode() / gpio_write() come from a native GPIO extension compiled into the firmware, and sys_delay() from a small sys extension alongside it.
sys_delay() deserves a note, because it is the kind of detail that separates a port from a demo. It maps to FreeRTOS's vTaskDelay, which yields the core rather than busy-waiting — so the hardware watchdog stays satisfied while your script sleeps. A loop() that never yields will trip it. (delay() is a plain alias, kept for the Arduino idiom — the example above uses it.)
Wiring the LED
A GPIO pin drives 3.3 V. Put a ~330 Ω resistor in series with the LED, between the pin and GND. A red LED is the safe choice; blue and white ones have a higher forward voltage and can be too dim to see at 3.3 V.
Inside the chip: memory, boot and the VM
This is the part I find most interesting, and the part that explains why the whole thing is possible at all on hardware this small.
Why a multi-megabyte image fits in a few hundred KB of RAM
The single most important fact about this platform is that code is never copied into RAM to run. It executes straight out of flash through the MMU cache — execute in place, "XIP". A cache miss stalls until the line is fetched, but the hot working set stays cached, so the amortized cost is low. The practical upshot: your image size is bounded by the flash partition, not by RAM.

The static footprint in internal SRAM is about 180 KB in total — roughly 95 KB of .bss, 15 KB of .data and 72 KB of .iram. Everything that grows while a script runs goes somewhere else entirely: into PSRAM.
That is deliberate at two levels. First, Zend's own arena allocator is switched off — the firmware sets USE_ZEND_ALLOC=0 before the engine starts, so every allocation routes through plain malloc/free. Second, ESP-IDF is configured to send malloc to PSRAM with an internal-allocation threshold of zero, so all allocations land there, not just large ones.
That second setting is a requirement rather than a tuning knob. PHP makes thousands of small allocations; with the default 16 KB "always internal" threshold they would fill internal SRAM and starve DMA — which the SD card needs — and the FreeRTOS objects, neither of which can live in PSRAM.
The measured result, from the serial log after a run:
| Example | Board | Heap free after the run |
|---|---|---|
hello |
ESP32-P4 | ~32.4 MB |
led-blink |
ESP32-P4 | ~31.2 MB — engine resident, holds ~1 MB and does not grow |
hello |
ESP32-S3 | ~8.5 MB |
On the P4 a build decision is almost always about flash, not RAM. On the S3, RAM is the number to watch — and it is where the one hard ceiling sits, which I will come back to.
One more number worth explaining: the PHP task runs with a 64 KB stack, which is enormous by embedded standards. Two concrete reasons. The PHP compiler recurses heavily while descending a syntax tree, and zend_bailout unwinds fatal errors with setjmp/longjmp, which needs the frames it jumps over to still be on the stack. With a smaller stack the board resets on trivial scripts.
From reset to the first byte

app_main() does almost nothing: it spawns one FreeRTOS task and returns. Everything else — mounting the microSD and the embedded FAT image, bringing the network up, resolving which entry script to run, seeding the OPcache ini defaults and the TLS CA path and the baked .env, starting the engine, registering per-project C extensions — happens inside that task.
Then take <?php echo 1 + 1;. The source goes into zend_compile_string(), which tokenizes it, builds a syntax tree and lowers it to opcodes. Those opcodes live in the PSRAM heap. zend_execute() runs the VM loop, taking one opcode at a time and calling the C function that implements it — ZEND_ADD, ZEND_ECHO, and the rest. ZEND_ECHO reaches the SAPI's output funnel and the bytes go to the serial console, or into an HTTP response.
It is exactly the path the code takes on a server. It just happens on a single core at a few hundred MHz.
The portable VM, and what is missing
Zend can generate several dispatch variants for its virtual machine. Desktop builds usually use the "goto" variant, which relies on GCC's computed-goto to thread from one opcode handler to the next. That does not compile cleanly on these targets, so this build uses the portable "call" variant instead: an ordinary switch/function-call dispatch loop. Behaviour is identical; only the mechanism differs.
That single choice is why the same engine source builds for RISC-V on the P4 and Xtensa on the S3 without touching any VM code.
A few things genuinely are not there. There is no dlopen, so nothing loads at runtime — every extension is compiled in and linked statically, from a hand-written table the firmware walks at startup. There are no POSIX signals under FreeRTOS and newlib, so ZEND_SIGNALS is off. And anything upstream that depends on fork, processes, shared memory, a dynamic loader, an interactive TTY or Windows COM is simply not portable here — a category that will never arrive, and the docs say so plainly rather than promising otherwise.
Three ways to run your code
One line in the project config decides how PHP runs on the board.

If the entry script defines a loop() function, the file is run once — which defines the functions — and then C calls setup() once and loop($tick) repeatedly.
Keeping the loop in C is deliberate. That is where each call into PHP is wrapped in zend_try/zend_catch, so a fatal error is logged and the loop continues instead of resetting the board. It is also where an uncaught exception gets cleared and logged, and where the cycle collector runs periodically — refcounting frees most garbage immediately, but cycles need a sweep, so gc_collect_cycles() runs every 256 ticks alongside a free-heap log line.
type = "init-loop"
A C HTTP server sits in front, and PHP runs fresh for each request — shared-nothing, exactly the way a script runs behind nginx and PHP-FPM. Each incoming request becomes a full CGI-style $_SERVER / $_GET / $_POST / $_COOKIE, the front controller runs, and its output plus whatever headers, status and cookies it set become the HTTP response.
The defining constraint is that PHP must run on the task with the 64 KB stack, not on the HTTP server's task. So the two tasks hand a single request back and forth through two binary semaphores: httpd parses the request off the socket, wakes php_task, and blocks; php_task runs one full request cycle and wakes httpd to send the response. Static files under public/ are served directly by the C task without a PHP cycle at all — the same idea as try_files $uri /index.php.
type = "web-server"
Point [php] entry at public/index.php and this is enough to make a framework browsable — routing, sessions, forms and all.
Reserved, not shipped yet. The idea is to sleep until something happens — a GPIO interrupt, a timer, an incoming packet — run a PHP handler, then go back to sleep.
That is the shape that matters for battery-powered devices, which spend almost all of their life idle. The other two models both assume the chip is awake and working; this one assumes the opposite.
Shared-nothing creates one obvious gap: a device serving HTTP usually has setup it wants to do once, and data it wants to share between requests. Two features fill it. mem_* is a volatile in-RAM key-value store that lives below PHP and therefore survives request teardown, and [web-server] init is a script the firmware runs once, before the HTTP server starts. One produces the data, the other holds it.
For anything that must survive a power cycle there is store_* instead, backed by the SoC's NVS — a wear-levelled, power-loss-safe key-value area in flash. A boot counter is two lines:
$boots = (int) store_get('boots', '0') + 1;
store_set('boots', (string) $boots);
Getting on a network
This is the newest part of the port and the one I am most pleased with. Until recently a networked board meant a wired one. As of 0.17 there are three ways onto a network, and your PHP does not change between them.

The wifi extension is opt-in — it costs about 600 KB of flash — and it does both sides of the job. As a client it scans and joins:
foreach (wifi_scan() as $ap) {
printf(" %-32s ch%-3d %4d dBm %s\n",
$ap['ssid'], $ap['channel'], $ap['rssi'], $ap['auth']);
}
if (wifi_connect($_ENV['WIFI_SSID'], $_ENV['WIFI_PASSWORD'])) {
echo "connected! IP " . wifi_ip() . " (" . wifi_rssi() . " dBm)\n";
}
It handles open, WPA2 and WPA2/WPA3-transition networks, is PMF-capable, does WPA3-SAE, scans every channel, and retries transient association drops — which is what it takes for a real phone hotspot to connect reliably rather than only in the lab. Credentials are never baked into the project config; PHP passes them at runtime, and the wifi-connect example reads them from a gitignored .env.
As an access point it goes the other way. wifi_ap_start() makes the board its own network, with a built-in DHCP server handing out addresses from 192.168.4.1, plus wifi_ap_ip(), wifi_ap_clients() and wifi_ap_stop().
The demo that ties it together
Put those pieces next to each other and something nice falls out. Every ESP32-S3 has a radio on the die, so as of 0.17 every S3 board offers the web-server model — previously that was reserved for boards with wired Ethernet. The HTTP server never cared where the network came from.
So: a [web-server] init script brings up a SoftAP before the server binds, and the per-request front controller serves a page over it. No router, no cable, no host computer.
if (wifi_ap_start(AP_SSID, AP_PASS)) {
echo "[init] access point '" . AP_SSID . "' up at " . wifi_ap_ip() . "\n";
}
mem_set('h', 210); mem_set('s', 255); mem_set('v', 40);
s3_onboard_rgb_hsv(210, 255, 40);
That s3_onboard_rgb_* call is the other new extension, added in 0.16: it drives the onboard WS2812 RGB LED straight from the SoC's RMT peripheral, no external component. It is ESP32-S3 only, and enabling it on a P4 board fails the build early with a clear message rather than at link time.
The full example — wifi-ap-s3-rgb-manage — starts the access point, serves a control page with hue, saturation and brightness sliders, and applies them live. The LED itself holds its colour between requests, and the slider positions live in mem_* so each fresh request renders where the LED actually is. It runs on an ESP32-S3-Zero, verified end to end on hardware.
A chip with no radio
The ESP32-P4 is the interesting case, because it has no built-in radio at all. Ask it and it tells you:
Chip: ESP32-P4 (revision v1.3)
Radio: none (no built-in WiFi/BT; this chip needs a companion for wireless)
The new esp32-p4-wifi-c6 board profile solves that with a companion: an on-board ESP32-C6 wired to the P4 over SDIO, running the ESP-HOSTED slave firmware. On the host side, esp_wifi_remote re-exposes the ordinary esp_wifi_* API and forwards every call to the companion over SDIO.
Because the API is identical, the wifi extension and your PHP code are unchanged — wifi_scan(), wifi_connect() and wifi_ap_start() behave exactly as they do on an S3. The host components are pulled automatically during a phpflash build for a P4 target, and if the companion came integrated on the board it is very probably already flashed and wired, so you flash only the P4.
With the P4's 32 MB of PSRAM behind it, that puts full frameworks over WiFi within reach.
What ships in the image
"Real PHP" is a claim that has to be paid for in kilobytes, so here is what is actually in there. A typical build lands around 3 MB — the measured baselines are ~3.09 MB on 8.3.33, ~3.20 MB on 8.4 and ~3.29 MB on 8.5. On the P4's 12 MB app partition, that is comfortable.
The engine itself — VM, compiler, GC, the object and class system — is roughly 1.27 MB, with ext/standard adding about 700 KB. Compiled in unconditionally alongside it: pcre, hash, json, spl, reflection and random. Those cannot be turned off, and together they are most of what people mean by "PHP works".
Everything else is a build flag with a known cost, and the sizes are measured image deltas rather than estimates:
| Extension | Flash cost | Note |
|---|---|---|
mbstring (full) |
~965 KB | The heavy one; mostly CJK conversion tables |
mbstring (no CJK) |
~209 KB | UTF-8, UTF-16 and Latin are unaffected |
openssl (subset) |
~42 KB | Symmetric crypto, backed by ESP-IDF's mbedTLS |
openssl (full) |
~2.1 MB | Real OpenSSL 3.0 libcrypto, cross-compiled |
date |
~650 KB | ~350 KB of that is the timezone database |
pdo_sqlite |
~560 KB | ~530 KB of that is SQLite itself |
opcache |
~500 KB | Zend OPcache, no JIT |
wifi |
~600 KB | Scan, join and SoftAP |
session |
~50 KB | With the files and user save handlers |
filter |
~27 KB | filter_var() |
ctype |
~2.5 KB | One source file, no data tables |
Networking is not an extension — it comes with the board and the project type. Ethernet adds about 103 KB, the web-server model another ~37 KB on top, and the TLS client about 180 KB. All small next to a single extension like date.
The OpenSSL story is a good illustration of how the port handles a genuine mismatch. PHP's ext/openssl is written against the OpenSSL C library, which does not exist on this target — ESP-IDF ships mbedTLS, a different API entirely. So the extension is delivered as two builds behind one module and one set of function names: a compact mbedTLS-backed subset for symmetric crypto, and the full OpenSSL 3.0 libcrypto actually cross-compiled for the chip, with RSA/EC, X.509 and on-chip key generation. Both are byte-for-byte interoperable with desktop OpenSSL.
With the full build and its tls setting, this works from the chip:
$body = file_get_contents('https://example.com/');
DNS, TCP and a certificate-verified TLS handshake against a shipped root-CA bundle, in one call, on a 32-bit microcontroller.
OPcache matters more here than almost anywhere else. Without an opcode cache, PHP retokenizes, reparses, recompiles and reoptimizes every file on every request; for a framework pulling in hundreds of files that dominates the request entirely. The bundled ext/opcache is really ported — no JIT, since these targets do not support it, and statically linked — with two modes: a file cache on the microSD (the default, which leaves the whole PSRAM free for the request) or an in-RAM segment held in PSRAM. In both modes validate_timestamps is off, so after the first request the compiler is skipped entirely.
And when PHP is genuinely the wrong tool for something, you drop C under firmware/exts/ and it compiles into the firmware — no fork required. That is exactly how the GPIO extension in the blink sketch gets there.
One small addition in 0.17 that I like more than its size suggests: phpinfo() now renders a PHP Baremetal Infos table right under the general one, showing the project name, the board, and the php-esp32 and ESP-IDF versions. The same values appear in $_SERVER as PHP_ESP32_*. Calling phpinfo() and getting a page that knows which board it is running on is a good moment.
Flash a board, and where this goes next
The toolchain is one static Go binary, phpflash. It hardcodes nothing about hardware or extensions: it reads a manifest from the installed firmware, presents only the choices that actually exist, records them in your project config, and emits exactly the build flags declared there.
-
01
Set up the machine, once
Installs ESP-IDF and the php-esp32 firmware sources under
~/esp. Once per machine, not per project.phpflash system-setup -
02
Scaffold a project
Asks a short series of questions — board, storage, execution model, extensions — then writes the config and a starter
project-src/index.php.phpflash init my-project && cd my-project -
03
Write your PHP
Edit
project-src/index.php, or point[php] entryat a front controller likepublic/index.phpif you are running a framework. -
04
Build
Turns the enabled extensions into build flags and drives ESP-IDF into the project's own
build/tree.phpflash build -
05
Flash and watch it
flashchecks the connected chip matches the project's board before writing;monitoropens the serial console. Leave it with Ctrl + ] .phpflash flash && phpflash monitor
One choice is worth understanding early. storage_type = "microsd" means your PHP is read from the card at boot, so swapping the program is swapping the card — no rebuild, no reflash. storage_type = "embedded" bakes the source into the firmware image. The first is wonderful while iterating; the second is what you ship.
Where it runs today
Two chip families, seven boards. Within each family the naming is consistent: -ETH has a wired network, -Pico is the same board without one, and -Zero is the minimal variant with no card slot either.
| Family | Core | Boards | Networking |
|---|---|---|---|
| ESP32-P4 | dual-core RISC-V, up to 400 MHz | P4-Zero, P4-Pico, P4-ETH, P4-WiFi-C6 | Ethernet on -ETH; WiFi 6 on -WiFi-C6 via the C6 companion |
| ESP32-S3 | dual-core Xtensa LX7, 240 MHz | S3-Zero, S3-Pico, S3-ETH | Ethernet on -ETH; native WiFi on all three |
Two properties decide whether a chip is a candidate at all. It needs external PSRAM, because the runtime heap is measured in megabytes and will not fit in a few hundred KB of internal SRAM. And it needs room in flash, 8 MB and up, for a ~3 MB image. Core architecture does not matter — the portable "call" VM builds on both Xtensa and RISC-V — and clock speed changes how fast it runs, not whether it runs. The ESP32 C and H series are out regardless of how fast they are: no PSRAM, so the heap has nowhere to live.
That criterion also explains the most instructive bug fixed in 0.16. The esp32-s3-zero profile had inherited the S3-ETH module's assumptions — 16 MB of flash, 8 MB of Octal PSRAM — but the real hardware carries 4 MB of flash and 2 MB of Quad PSRAM. In Octal mode a Quad module never initialises, and with USE_ZEND_ALLOC=0 the entire runtime heap lives in PSRAM, so the engine had nowhere to run. Two lines of board config, and a whole class of "why does nothing work" disappears.
The honest ceiling is on the S3 generally: plain applications and a live web server run comfortably, but a full framework's container-compile step wants more than 8 MB of PSRAM and runs out. That is written down in the docs rather than glossed over, and it is the reason the P4 is the framework tier.
Everything board-specific — SD pins and power, the Ethernet controller, mount and network bring-up — lives behind a small board.h contract, so the firmware never learns whether storage is 4-bit SDIO or SPI, or whether the network is an internal MAC, a W5500 or a companion radio over SDIO. Which is also the argument for what comes next: a new chip family is a directory, not an engine change. NXP's i.MX RT crossover parts are the next family on the roadmap; other parts with external RAM — an STM32H7 with SDRAM, an RP2350 with QSPI PSRAM — are candidates worth exploring. ESP32 is the current target, not the identity.
On the extension side, bcmath and phar are the next realistic ports — self-contained, no external library, nothing missing from the OS underneath.
What this blog will be
Release notes with the reasoning behind them, deep dives into the parts of the port that were hard, benchmarks with real numbers, and recipes for things people actually want to build. It is also the official channel for what is coming — so this is the place to watch for the next releases. If you want to start now, the documentation is the place to go — php-esp32 for the firmware, flash-tool for the CLI, and the 1.0.0 changelog for everything in this release.
And if you have a board on your desk and half an hour, blink an LED with it. It is a strange feeling the first time.
php-baremetal is an independent project. PHP is developed by the PHP Group; the engine here is the upstream release from php.net, unmodified. ESP32, ESP-IDF and ESP-HOSTED are trademarks or projects of Espressif Systems. Laravel, Symfony, Composer and Packagist are trademarks of their respective owners — the project runs their unmodified packages and is not affiliated with or endorsed by any of them.
Keep reading