Blog 13 min read

PHP on the ESP32 now reacts: events, WebSocket and a second core

Tilt the board and the plot moves in your browser. php-esp32 1.4.0 lets PHP sleep until the hardware wakes it: typed events, live WebSocket, and a second core.

Gianfrancesco Aurecchia

@GianfriAur
php-esp32 1.4.0 The Reactor: an event listener, watch_gpio and serve_ws in PHP beside a microcontroller glyph

php-esp32 1.4.0 adds a third way to run PHP on the chip. Until now a script either ran top to bottom with setup() and loop(), or answered one HTTP request at a time behind the built-in server. The new event-driven model does neither: the script registers listeners once, then goes to sleep, and hardware, timers and network traffic wake it up with typed events.

This is still the real, unmodified PHP interpreter from php.net. Nothing new was added to the language. The reactor is C firmware around it that decides when PHP runs.

imu-ws-stream: the board creates its own WiFi network, and tilting it moves the accelerometer and gyroscope plots live in the phone's browser over WebSocket

Events, and a model built on them

An event is a PHP class. Extend Baremetal\Event and you have one. Events::listen() subscribes a handler, Events::now() delivers an event immediately, and Events::dispatch() queues it. Queued events are handled in order after the current one finishes, and a handler that returns false stops the others from seeing that event. The event object itself is the payload, so a handler receives a typed object rather than an array.

The event-driven execution model is built on this. There is no loop(). The script runs once to register listeners and start event sources, then control passes to a reactor on core 0 that waits on an event queue. When an event arrives, the reactor calls its listeners; when nothing is happening, the CPU idles instead of spinning in a loop.

php index.php
use Baremetal\Event;
use Baremetal\Events;
 
final class Tick extends Event {}
final class BootPressed extends Event {}
 
Events::listen(Tick::class, fn (Tick $e) => print("tick\n"));
Events::listen(BootPressed::class, fn () => print("BOOT pressed\n"));
 
every(1000, Tick::class);          // timer source
watch_gpio(0, BootPressed::class); // debounced GPIO interrupt

every() and watch_gpio() are sources: C code that creates a typed event and passes it to the reactor. GPIO is a general-purpose input/output pin, and watch_gpio() fires on a falling edge with debouncing, so one button press is one event. The whole system is off unless a project asks for it, so a build that doesn't use events carries none of the cost.

How the reactor works: sources such as every(), watch_gpio(), a core-1 poller, serve_http() and serve_ws() put typed events on one queue; the PHP reactor on core 0 dispatches them to listeners, which answer with a Response, ws_broadcast() or GPIO writes

The second core does the polling

The ESP32-S3 and ESP32-P4 both have two cores, but PHP runs on one. In 1.4.0 the other core runs a small background executor for sensors that need reading at a steady rate. $imu->poll(hz: 50, depth: 8) makes core 1 read the IMU (inertial measurement unit, the accelerometer and gyroscope chip) 50 times a second into a ring buffer. PHP then reads the most recent sample with sample(), or everything collected so far with drain(), without waiting on the I²C bus.

The poller can also generate events. Pass event: and core 1 sends a SamplesReady event for each new sample, which the reactor delivers on core 0 with $e->device set to the sensor that produced it:

php imu-events · index.php
use Baremetal\Events;
use Baremetal\I2c\Bus;
use Baremetal\I2c\Driver\Qmi8658;
use Baremetal\Sensor\Imu\SamplesReady;
 
$imu = new Qmi8658(new Bus(sda: 11, scl: 10));
 
Events::listen(SamplesReady::class, function (SamplesReady $e): void {
    foreach ($e->device->drain() as $raw) {
        ['accel' => [$ax, $ay, $az]] = $e->device->decode($raw);
        printf("accel = [% .2f % .2f % .2f] g\n", $ax, $ay, $az);
    }
});
 
$imu->poll(hz: 10, depth: 32, event: SamplesReady::class);

SamplesReady belongs to the IMU capability rather than to one chip, so the same handler will work with any polled IMU driver. That is the imu-events example.

What those two lines actually do

Those two lines hide quite a lot of work. Here is what happens from the moment PHP evaluates them down to the I²C wire, and on to the second core.

The exact flow of new Qmi8658(new Bus(sda: 11, scl: 10)) and $imu->poll(hz: 10, depth: 32, event: SamplesReady::class): the bus and device are opened through a C registry and ESP-IDF, the sensor is configured over I²C, the poller starts a task on core 1, and every 100 ms one 12-byte sample travels from the wire to the ring buffer and on to the PHP listener

A · new Bus(sda: 11, scl: 10). PHP evaluates the inner expression first. The constructor looks the pins up in a small C registry of four buses. If a bus is already open on SDA 11 and SCL 10, you get that same bus back, which is why a second new Bus() on the same pins is harmless. Otherwise ESP-IDF's i2c_new_master_bus() claims the two GPIOs, with internal pull-ups on, and the registry creates a mutex that serializes every transfer on that bus.

B · new Qmi8658($bus). With no address, the driver uses its first default, 0x6B, at 400 kHz. It doesn't fall back to 0x6A, so pass the address if your board wires it differently. The device gets a slot in a 32-entry registry, and the driver's init() talks to the chip. It reads WHO_AM_I, and anything other than 0x05 throws cannot init qmi8658 at 0x6B. It then enables register auto-increment, sets the ranges to ±4 g and ±512 dps, switches both sensors on and waits 10 ms. The PHP object is only a handle on that registry slot, and its destructor never touches the wire, so the same code works in every execution model.

C · $imu->poll(hz: 10, depth: 32, event: SamplesReady::class). The driver declares a poll routine that reads 12 bytes. executor_poll() turns 10 Hz into a 100 ms period and allocates a ring of 32 × 12 bytes. The first poller also starts the executor task, pinned to core 1. Because event: is given, SamplesReady becomes a numeric tag on the event queue, and the device is registered as the event's emitter. That adds a reference to $imu, so it stays alive even if your variable goes out of scope. poll() returns immediately and the script carries on.

D · then, every 100 ms. Core 1 reads registers 0x35 onward in one transfer, taking the bus lock: six little-endian 16-bit values for accelerometer and gyroscope X, Y and Z. It copies them into the ring and posts {tag, $imu} to the queue. On core 0 the reactor builds a SamplesReady with $e->device set to that same $imu object. drain() copies the waiting samples out of the ring, up to 256 per call, and decode() scales them: raw ÷ 8192 gives g, raw ÷ 64 gives degrees per second.

Calling accel() while polling

accel(), gyro() and temp() still read the chip directly from core 0. That's safe while the poller runs, because both sides take the same bus mutex, but it is real bus traffic. For motion data you're already polling, sample() or drain() is cheaper. The imu-ws-stream demo reads temp() directly only every fourth tick, because the temperature isn't part of the polled sample.

HTTP and WebSocket as event sources

With [extensions.web] enabled, the network becomes a source too. serve_http(80) starts a server whose requests reach the reactor as Baremetal\Http\Request events. A listener returns a Baremetal\Http\Response, and the firmware sends it back on the same connection:

php http-events · index.php
use Baremetal\Events;
use Baremetal\Http\Request;
use Baremetal\Http\Response;
 
Events::listen(Request::class, fn (Request $r): Response => match (true) {
    $r->method === 'GET'  && $r->path === '/status' => Response::json(['uptime' => sys_uptime_ms()]),
    $r->method === 'POST' && $r->path === '/echo'   => Response::json($r->json()),
    default => Response::notFound(),
});
 
serve_http(80);

The firmware has no router; routing is plain PHP, as above. The first listener that returns a Response answers. An unmatched path gets a 404, a handler that throws gets a 500, and a connection is never left waiting. $_GET, $_POST, $_REQUEST and $_SERVER are filled in as usual, so existing request-handling code keeps working. Every method, GET, POST, PUT, PATCH, DELETE, OPTIONS and HEAD, has been tested on hardware.

serve_ws('/ws') adds a WebSocket endpoint to the same server. Each incoming frame arrives as a Baremetal\Http\Message with ->text, ->client and ->reply(). ws_broadcast($data) sends a frame to every connected client, which is how a sensor stream or status update reaches browsers without them asking.

No dropped frames

Incoming WebSocket frames apply backpressure instead of being dropped. When the event queue is full, the server stops reading and TCP slows the sender down. On hardware, a burst of 200 frames, well past the queue depth, arrived with none lost.

Under the hood: one event, step by step

The events above are PHP objects, but most of the work happens in C, split across the two cores. This diagram follows one IMU sample from the second core to a PHP handler and back. Every step comes from the firmware source.

Execution cycle across the two cores: setup registers listeners and starts the sources; then the core-1 executor samples the IMU into a ring buffer and posts a small message to a FreeRTOS queue; the core-0 reactor wakes, builds the PHP event object and calls the listeners, which read the ring with drain(); after the handler, C answers pending HTTP requests, runs the garbage collector every 256 events and waits again

Setup runs once (steps 1 and 2). index.php runs from top to bottom on core 0, inside the php_task FreeRTOS task. Each call there sets something up in C. Events::listen() adds to the listener table, every() creates an esp_timer, watch_gpio() installs a pin interrupt, serve_http() starts ESP-IDF's HTTP server, and $imu->poll() starts the executor task, pinned to core 1. When the script ends, the firmware freezes the listener table (a later Events::listen() throws) and enters the loop.

Then the loop repeats for every event (steps 3 to 9).

  • Step 3 · core 1, C. The executor checks its pollers on every scheduler tick. For each poller that is due, it reads the sensor over I²C, copies the sample into the ring buffer under a spinlock, and, if poll() was given event:, posts a message to the queue. The message is just {tag, kind, ptr}: a number standing for the event class, and a pointer to the sensor. No PHP code runs on core 1.
  • Step 4 · other C producers. Timer callbacks, the GPIO interrupt (after 200 ms of debounce) and the HTTP server post the same kind of message. Their overflow behavior differs. When the 32-slot queue is full, timer, GPIO and poller messages are dropped rather than blocking. An HTTP request gets a 500 reactor queue full. A WebSocket frame waits up to two seconds, which is where the backpressure comes from.
  • Step 5 · core 0, C. The reactor waits in xQueueReceive() with no timeout. While nothing arrives, FreeRTOS runs its idle task, so the CPU isn't spinning.
  • Step 6 · core 0, C. For a source message, C creates the PHP object with object_init_ex() for the class the tag stands for, sets $e->device, and calls the listeners. This is the only place a PHP value is created, and it is always on core 0.
  • Step 7 · core 0, PHP. Your listeners run in the order they were registered, until one returns false or throws.
  • Step 8 · the ring. When a handler calls $e->device->drain() or sample(), C copies samples out of the ring buffer under the same spinlock the executor uses. That reads memory, not the I²C bus, so the handler never waits on the sensor.
  • Step 9 · core 0, C. Each delivery runs inside zend_try, so a fatal error in a handler is logged and the loop carries on. For HTTP, the first listener that returns a Response answers. C copies the response into the request slot and releases a semaphore, and the HTTP task sends the response on the same socket. One request is handled at a time. Every 256 events the reactor calls gc_collect_cycles(), then goes back to step 5.

Why it is split this way

PHP only ever runs on core 0. The only things that cross from one core to the other are fixed-size queue messages and byte copies under a spinlock, so the Zend engine never has to be thread-safe. The second core still does useful work: it handles steady-rate bus reads, the kind of work that would otherwise stall a PHP handler.

The demo: imu-ws-stream

The video at the top is imu-ws-stream, which combines all of this in one script. The board starts its own WiFi access point, so no router is needed. Core 1 polls a QMI8658 IMU at 50 Hz. On core 0, a 20 Hz timer event reads the latest sample and sends it with ws_broadcast(), and the same server delivers the page and its JavaScript over HTTP.

imu-ws-stream: on core 1 the QMI8658 is polled at 50 Hz into a ring buffer; on core 0 a 20 Hz timer reads the latest sample and sends it with ws_broadcast() to every connected browser

The broadcast doesn't run once per sample. The poller fills the ring at 50 Hz, and a separate 20 Hz clock sends whatever is newest. That gives the reactor a steady, predictable load instead of bursts that compete with HTTP requests, and the plots stay smooth.

  1. 01

    Flash it

    Build and flash the example, then open the serial monitor to see the access-point details.

    phpflash flash
    phpflash monitor
    
  2. 02

    Join the network

    Connect to the php-imu WiFi network (password baremetal).

  3. 03

    Open the page

    Browse to http://192.168.4.1/ and tilt the board. Open it on a second device too: both receive the same stream.

How we got here: 1.2.0 and 1.3.0

1.4.0 builds on the two releases before it, which added PHP objects for hardware buses.

Release timeline: v1.2.0 added the I²C bus and drivers, v1.3.0 the SPI/QSPI bus and the ST77916 display, and v1.4.0 the event-driven reactor

1.2.0: the I²C bus and drivers. Baremetal\I2c\Bus represents an I²C bus as a PHP object. new Bus(sda: 11, scl: 10) returns the same underlying bus every time for the same pins. scan() lists every address that responds, and readReg() and writeReg() talk to a device directly. On top of that is a driver framework: drivers are real PHP classes, chosen per project in the manifest, and they implement capability interfaces such as Baremetal\Sensor\Imu, so application code can check instanceof Sensor\Imu without naming a chip. The first driver is the QMI8658 IMU, with accel(), gyro() and temp(), and the 1.4.0 poller reads that same driver.

1.3.0: SPI/QSPI and the first display. Baremetal\Spi\Bus is the SPI counterpart, including QSPI (four data lines, for fast devices such as displays). It comes with a display capability, Baremetal\Output\Display, and a first panel driver, the ST77916. This release is a minimal proof of concept: the panel detects which initialization it needs and lights up, with fill() and rgb(). Framebuffers and text come later. 1.3.0 also lets a project's own C extensions depend on additional ESP-IDF components, such as esp_lcd.

Both buses work in every execution model, including the new one. Each release adds one layer: buses and drivers, then displays, then a reactor that lets them all run from a single PHP program.

Try it

phpflash init offers event-driven as a project type on every board. The fastest way to see it working is the event-driven-hello example, then http-events over WiFi or http-events-eth over Ethernet. The full list of changes is in the changelog, and the setup guide is in the documentation.

Next on the roadmap are the rest of 2.0.0 (Bluetooth, touch input, and a full display driver layer) and, in 2.5.0, per-device listeners like $panel->on(Tapped::class, …).

ESP32 is a trademark of Espressif Systems. php-baremetal is an independent project, not affiliated with or endorsed by Espressif or the PHP Group.

Keep reading