zphp
zphp is a PHP runtime written in Zig. It can run PHP scripts, serve HTTP, manage packages, run tests, and format code.
$ zphp serve app.php --port 8080
listening on http://0.0.0.0:8080 (14 workers)
What's in the box
| Command | What it does |
|---|---|
zphp run <file> | Execute a PHP script |
zphp serve <file> | HTTP server with TLS, HTTP/2, WebSocket, gzip |
zphp test [file] | Test runner with built-in assertions |
zphp fmt <file>... | Code formatter |
zphp build <file> | Compile to bytecode |
zphp build --compile <file> | Compile to a standalone executable |
zphp install | Install packages from composer.json |
zphp add <pkg> | Add a package |
How it relates to PHP
zphp implements PHP syntax and standard library behavior, with compatibility tests against PHP and application harnesses for projects including Laravel and WordPress. This does not guarantee that every PHP application or extension works unchanged. See compatibility and differences.
Its built-in server and development tools reduce the number of separate programs needed for supported workflows. They are not drop-in implementations of Composer, PHPUnit, or PHP-CS-Fixer.
zphp can also run PHP on several threads at once. A worker pool spreads CPU-bound tasks across cores, channels pass values between threads while tasks run, and buffers move binary data to another thread without copying it.
Unused values are reclaimed during execution, supporting long-running command-line workloads as well as request-based applications. Like traditional PHP, zphp uses reference counting and cycle collection. See the memory model.
Installation
Prebuilt binaries
Download the latest release for your platform from GitHub Releases.
| Platform | Binary |
|---|---|
| Linux x86_64 | zphp-linux-x86_64 |
| Linux ARM64 | zphp-linux-aarch64 |
| Linux x86_64 (musl) | zphp-linux-x86_64-musl |
| Linux ARM64 (musl) | zphp-linux-aarch64-musl |
| macOS Apple Silicon | zphp-macos-aarch64 |
Move it somewhere in your PATH:
$ mv zphp-linux-x86_64 /usr/local/bin/zphp
$ chmod +x /usr/local/bin/zphp
Building from source
Requires Zig 0.15.1 and the system libraries below. Run build commands from the repository root.
Ubuntu/Debian:
$ sudo apt-get install -y libpcre2-dev libsqlite3-dev zlib1g-dev \
libmysqlclient-dev libpq-dev libssl-dev libnghttp2-dev libcurl4-openssl-dev \
libxml2-dev libicu-dev libgmp-dev libgd-dev libsodium-dev libldap2-dev
$ zig build -Doptimize=ReleaseFast
$ ./zig-out/bin/zphp --version
macOS (Homebrew):
$ brew install mysql-client libpq openssl@3 nghttp2 curl libxml2 icu4c gmp gd libsodium openldap
$ make release
$ ./zig-out/bin/zphp --version
Source builds put the executable at zig-out/bin/zphp. Add zig-out/bin to your PATH or use ./zig-out/bin/zphp in the commands below. CI uses macOS 15; Zig 0.15.1 has known linking problems with the macOS 26 SDK.
Verify it works
$ echo '<?php echo "hello from zphp\n";' > hello.php
$ zphp run hello.php
hello from zphp
Your First Script
Running a script
Create a file called app.php:
<?php
$name = "world";
echo "Hello, $name!\n";
$numbers = [1, 2, 3, 4, 5];
$doubled = [];
foreach ($numbers as $n) {
$doubled[] = $n * 2;
}
echo implode(", ", $doubled) . "\n";
Run it:
$ zphp run app.php
Hello, world!
2, 4, 6, 8, 10
Serving an application
Create a file called server.php:
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['REQUEST_URI'];
echo json_encode([
'method' => $method,
'path' => $path,
'message' => 'Hello from zphp',
]);
Serve it:
$ zphp serve server.php --port 3000
$ curl http://localhost:3000/api/hello
{"method":"GET","path":"\/api\/hello","message":"Hello from zphp"}
The command starts zphp's built-in HTTP server. See Serving an Application for the full details.
Serving an Application
zphp serve runs the built-in HTTP server:
zphp serve app.php --port 3000 --workers 8
The entry point is compiled at startup. Each worker keeps a persistent VM, with request state reset before each PHP request. Values are reclaimed during execution, not only at request boundaries. Compiled bytecode is retained across requests, including cached includes and directly requested PHP scripts.
Options
| Flag | Default | Description |
|---|---|---|
--port <N> | 8080 | Port to listen on |
--workers <N> | CPU count | Number of worker threads |
--tls-cert <file> | None | TLS certificate |
--tls-key <file> | None | TLS private key |
--watch | Off | Restart workers when PHP file changes are detected under the document root |
The server binds to all IPv4 interfaces. Use firewall rules or a reverse proxy to control access. Provide both TLS flags to enable HTTPS.
Request handling
Existing .php files under the document root execute directly. Other dynamic paths run the entry point from the top. Non-PHP files can be served without executing PHP; see Static Files.
The server populates request superglobals, including $_SERVER and $_GET. Form bodies populate $_POST, and multipart uploads populate $_FILES. Read a raw request body through php://input.
<?php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_URI'] === '/health') {
echo json_encode(['status' => 'ok']);
} else {
http_response_code(404);
echo json_encode(['error' => 'not found']);
}
For HTTP/1.1 responses, use header() and header_remove() to manage headers, http_response_code() for status, and setcookie() for cookies. Keep-alive is supported. Compressible responses are gzip-compressed when the client advertises gzip support.
The HTTP/2 response path currently sends the status and content type, but does not forward custom response headers or cookies. It also does not apply gzip compression. See TLS and HTTP/2.
At startup, a .env file in the working directory is loaded into the environment and exposed through $_ENV. Use --watch during development, or restart the server after deploying changed PHP code.
TLS and HTTP/2
zphp uses OpenSSL for TLS and nghttp2 for HTTP/2.
Enabling TLS
Provide a certificate and private key together:
zphp serve app.php --tls-cert cert.pem --tls-key key.pem --port 8443
TLS connections negotiate HTTP/2 through ALPN when the client supports it, with HTTP/1.1 as the fallback. Plain HTTP does not enable HTTP/2.
The HTTP/2 implementation handles streams and HPACK header compression, but its response path does not yet forward custom PHP headers or cookies. Gzip and static-file caching headers are only implemented on the HTTP/1.1 path. Applications that depend on these features should use an HTTP/1.1 backend, for example behind a TLS-terminating reverse proxy.
Local development
Generate a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj '/CN=localhost'
zphp serve app.php --tls-cert cert.pem --tls-key key.pem --port 8443
Test it with curl -k https://localhost:8443/. The -k flag disables certificate verification; use it only for local testing.
Deployment
Use a certificate from your certificate authority, with --tls-cert pointing to the full chain and --tls-key to the private key. Alternatively, terminate TLS at a reverse proxy and forward HTTP/1.1 requests to zphp.
Static Files
zphp serve serves static files automatically from the same directory as your PHP file. Keep private files outside this directory. The static-file handler has no general dotfile or secret-file exclusion, and symlinks can expose files outside the document root.
How it works
When a request comes in, zphp checks if the path maps to a file on disk in the document root (the directory containing your PHP entry point). If the file exists and isn't a .php file, it's served directly. Existing .php files execute directly; missing paths fall back to your PHP entry point.
project/
app.php <- entry point
style.css <- served as static file
script.js <- served as static file
images/
logo.png <- served as static file
$ zphp serve project/app.php
GET /style.cssservesproject/style.cssGET /images/logo.pngservesproject/images/logo.pngGET /anything-elseexecutesproject/app.php
Supported content types
zphp selects Content-Type by extension. Common mappings are listed below; HTML, CSS, JavaScript, and JSON also include charset=utf-8. Unknown extensions use application/octet-stream:
| Extensions | Content-Type |
|---|---|
.html, .htm | text/html |
.css | text/css |
.js, .mjs | application/javascript |
.json | application/json |
.png | image/png |
.jpg, .jpeg | image/jpeg |
.gif | image/gif |
.svg | image/svg+xml |
.ico | image/x-icon |
.webp | image/webp |
.woff, .woff2 | font/woff, font/woff2 |
.pdf | application/pdf |
.wasm | application/wasm |
Caching
Over HTTP/1.1, static files are served with:
- ETag headers based on file size and modification time in seconds
- Cache-Control: public, max-age=3600 (1 hour)
- Automatic 304 Not Modified responses when the client sends a matching
If-None-Matchheader
Compression
Over HTTP/1.1, compressible static files up to 1 MiB are gzip-compressed when the client advertises gzip support.
The HTTP/2 static-file path does not send ETags or Cache-Control, handle conditional requests, or apply gzip compression. Files larger than 10 MiB fall through to PHP handling on that path.
WebSockets
zphp serve supports WebSocket upgrades over HTTP/1.1. Declare ws_onMessage in the entry point so the server detects it when compiling the application. Defining it only in a runtime include does not enable WebSocket handling.
<?php
function ws_onOpen($conn) {
$conn->send('welcome');
}
function ws_onMessage($conn, $message) {
$conn->send('echo: ' . $message);
}
function ws_onClose($conn) {}
zphp serve ws_app.php --port 8080
Connect to ws://localhost:8080/. With TLS configured, use wss://localhost:8080/ instead. WebSocket upgrades are not implemented on the HTTP/2 path.
Connection object
Handlers receive a WebSocketConnection object. ws_onOpen and ws_onClose are optional.
| Method | Description |
|---|---|
$conn->send($message) | Send a text message |
$conn->close() | Close the connection |
State limitations
The entry point runs on the first WebSocket connection handled by each worker. PHP globals are worker-local, so an array of clients cannot broadcast across all workers.
Regular HTTP requests can use the same port, but they reset the same worker VM used by WebSocket callbacks. Do not rely on PHP globals or connection objects surviving mixed HTTP and WebSocket traffic. Use a separate WebSocket process rather than treating this as a shared-state chat server.
Worker Pools
PHP runs your code on one thread. Zphp\Pool runs PHP on several threads at once, so CPU-bound work spreads across cores inside a single process. PHP needs a thread-safe build and the parallel extension for this; zphp has it built in.
Each worker thread owns its own VM for the life of the pool. Workers never share PHP variables. A task's arguments are copied into the worker, and its result is copied back. Code that never creates a pool pays nothing for this.
This computes checksums for three files at the same time:
<?php
$pool = new Zphp\Pool(workers: 4);
$files = ['january.csv', 'february.csv', 'march.csv'];
$checksums = [];
foreach ($files as $file) {
$checksums[$file] = $pool->submit('md5_file', [$file]);
}
foreach ($checksums as $file => $future) {
echo $file, ': ', $future->await(), "\n";
}
january.csv: 56153e6036e3e33e8524168b0803204d
february.csv: 214ec5aa5320ad8d9cde0dd99db917dd
march.csv: e6e5eeeb53c8b7311acfdccbfe60cefd
submit hands the task to a free worker and returns a Zphp\Future right away. await() waits for that task and returns what it returned.
Splitting 32 CPU-bound tasks over more workers scales with the number of cores. On an Apple M4 Pro, with a release build, the same work took 240 ms on one worker, 123 ms on two, 63 ms on four, and 32 ms on eight.
Creating a pool
new Zphp\Pool(workers: 4, bootstrap: __DIR__ . '/worker.php', queue: 1024);
| Argument | Default | Meaning |
|---|---|---|
workers | CPU count | Number of worker threads |
bootstrap | none | A script each worker runs once at startup |
queue | 1024 | Tasks that can wait for a free worker |
The bootstrap script is where workers load an autoloader, define functions, or open connections they reuse between tasks. Each worker keeps its globals, static variables, and loaded classes from one task to the next. If the bootstrap throws, the constructor throws Zphp\PoolException with its message.
Submitting tasks
submit($callable, $args) queues a task and returns a Zphp\Future. The callable is a closure, a function name, 'Class::method', or [ClassName::class, 'method']. Named functions and classes must exist in the worker, which usually means the bootstrap defines or autoloads them.
<?php
// worker.php
function slugify(string $title): string
{
return trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($title)), '-');
}
<?php
$pool = new Zphp\Pool(workers: 2, bootstrap: __DIR__ . '/worker.php');
echo $pool->submit('slugify', ['Hello, World!'])->await(), "\n";
hello-world
A closure travels with its compiled code and its captured variables. use variables, $this for a bound closure, and the variables an arrow function reads from the enclosing scope are copied at submit time. Capturing by reference (use (&$x)) is refused, because the worker cannot write back into the caller's variable.
When the queue is full, submit waits for room. trySubmit returns null instead, so a producer can shed load rather than block.
Waiting for results
$future->await() blocks until the task finishes and returns its result. An exception thrown in the task is thrown again from await(), with the same class, message, and code. If the class only exists in the worker, it arrives as Zphp\TaskException with the original class name in the message.
await($seconds) gives up after the timeout with Zphp\TimeoutException. The task keeps running, and a later await() still gets its result. $future->isDone() checks without waiting.
<?php
$pool = new Zphp\Pool(workers: 2);
$average = fn(array $values) => intdiv(array_sum($values), count($values));
try {
$pool->submit($average, [[]])->await();
} catch (DivisionByZeroError $e) {
echo 'Could not average: ', $e->getMessage(), "\n";
}
Could not average: Division by zero
<?php
$report = $pool->submit(function () {
sleep(1);
return 'report ready';
});
try {
$report->await(0.1);
} catch (Zphp\TimeoutException) {
echo "still working\n";
}
echo $report->await(), "\n";
still working
report ready
To handle results in completion order instead of submission order, call $pool->collect($seconds). It returns the next finished future, or null when nothing finishes within the timeout.
<?php
$pool = new Zphp\Pool(workers: 3);
$build = function (string $name, int $ms) {
usleep($ms * 1000);
return $name;
};
$pool->submit($build, ['yearly report', 300]);
$pool->submit($build, ['daily report', 100]);
$pool->submit($build, ['monthly report', 200]);
while ($future = $pool->collect(timeout: 1)) {
echo $future->await(), " finished\n";
}
daily report finished
monthly report finished
yearly report finished
To wait on futures together with channels, or on futures from more than one pool, use Zphp\select.
An event loop can watch $pool->readiness() instead of polling. It returns a stream that becomes readable when a completed task is waiting, so it can go into stream_select() next to sockets.
Cancellation and shutdown
$future->cancel() removes a task that has not started and returns true. A running task is never interrupted. Instead, Zphp\Task::cancelled() starts returning true inside it, and the task decides when to stop. Awaiting a task that was removed from the queue throws Zphp\CancelledException.
<?php
$job = $pool->submit(function () {
while (!Zphp\Task::cancelled()) usleep(1000);
return 'stopped early';
});
usleep(20_000);
$job->cancel();
echo $job->await(), "\n";
stopped early
Zphp\Task::id() and Zphp\Task::worker() identify the current task and worker from inside a task.
$pool->shutdown() stops accepting tasks, cancels the queued ones, waits for running tasks, and joins the threads. shutdown($seconds) returns false if tasks are still running when the timeout passes, and a later shutdown() waits for them. Futures stay valid after shutdown.
What can cross between threads
Arguments and results are copied between VMs. These values can cross:
null, booleans, integers, floats, and strings- arrays of values that can cross
- objects whose class exists on both sides, with their properties
- channels, which are shared rather than copied
- buffers, which are moved rather than copied
Closures inside arguments or results, generators, fibers, pools, futures, streams and other resources, and objects that wrap a native handle (a PDO connection, a cURL handle) cannot cross. Passing one throws Zphp\TransferException naming where it was found, such as args[0]->connection. Open files and connections in the worker instead, usually in the bootstrap script.
Copying is proportional to the size of the value. For large binary data, use a buffer: its bytes move to the worker without a copy.
Channels
A Zphp\Channel is a bounded queue that threads use to pass values to each other. Unlike other values, a channel is shared when it crosses to a worker: the main thread and every worker that receives it operate on the same queue. The values sent through it are copied, following the same rules as task arguments.
Channels let a task keep receiving values while it runs, instead of getting one set of arguments up front. Here a worker counts error lines while the main thread is still sending them:
<?php
$pool = new Zphp\Pool(workers: 1);
$lines = new Zphp\Channel(capacity: 100);
$counter = $pool->submit(function (Zphp\Channel $lines) {
$errors = 0;
foreach ($lines as $line) {
if (str_contains($line, 'ERROR')) {
$errors++;
}
}
return $errors;
}, [$lines]);
$log = [
'10:00:01 INFO server started',
'10:00:02 ERROR disk full',
'10:00:03 INFO retrying',
'10:00:04 ERROR disk still full',
];
foreach ($log as $line) {
$lines->send($line);
}
$lines->close();
echo $counter->await(), " errors\n";
2 errors
The foreach in the worker ends when the main thread closes the channel.
Sending and receiving
| Method | Behavior |
|---|---|
new Zphp\Channel(capacity: 1) | Creates a channel that holds up to capacity values |
send($value, $seconds = null) | Waits while the channel is full; throws Zphp\TimeoutException if the timeout passes first |
trySend($value) | Returns false instead of waiting when the channel is full |
recv($seconds = null) | Waits for a value; throws Zphp\TimeoutException if the timeout passes first |
close() | Stops new sends; values already queued can still be received |
isClosed(), count(), capacity() | Report the channel's state |
recv(0) returns a value only if one is already queued. There is no tryRecv, because null is a value a channel can carry.
Sending to a closed channel, or receiving from one that is closed and empty, throws Zphp\ChannelException. A thread blocked in send or recv wakes up when another thread closes the channel.
Iterating
foreach over a channel receives values until the channel is closed and drained. Several threads can iterate the same channel, and each value goes to exactly one of them, so submitting the counter above twice would split the lines between two workers. Keys count up from zero for each consumer.
The capacity bounds how far a producer can run ahead of its consumers. A producer that fills the channel waits until a consumer makes room, so memory stays bounded even when one side is faster.
Waiting on several sources
Zphp\select($sources, $seconds = null) waits until any of several channels or futures is ready. It returns a two-element array of the ready source's key and its value, or null if the timeout passes first. For a channel, the value is the item it received; the receive happens inside select, so when several threads select on the same channel, each item still goes to exactly one of them. For a future, the value is the future itself, finished, so await() returns its result or throws its exception without waiting.
This downloads the same file from two mirrors and uses whichever answers first:
<?php
$pool = new Zphp\Pool(workers: 2);
$download = fn(string $url) => file_get_contents($url);
$mirrors = [
'europe' => $pool->submit($download, ['https://eu.example.com/app.zip']),
'america' => $pool->submit($download, ['https://us.example.com/app.zip']),
];
$first = Zphp\select($mirrors, 10);
if ($first === null) {
echo "No mirror answered within 10 seconds\n";
} else {
[$mirror, $future] = $first;
echo "Fastest mirror: $mirror\n";
$zip = $future->await();
}
The slower download keeps running in its worker, and $pool->shutdown() waits for it.
Channels and futures can be mixed in the same call, such as Zphp\select(['progress' => $channel, 'done' => $future]) to print progress messages from a task until it finishes.
With futures from several pools, select returns them in the order they finish. A finished future stays ready, so remove it from the array once it has been handled, or the next select returns it again.
When several sources are ready, select rotates which one it checks first, so a busy channel cannot starve the others. A channel that is closed and has nothing left is skipped. If every source is such a channel, select throws Zphp\ChannelException, the same as recv would. A timeout of 0 checks each source once without waiting.
Channels in values
A channel can travel inside any value that crosses threads: in task arguments, in a task's result, or inside a message sent on another channel. A worker can create a channel and return it, and the caller then shares that channel with the worker. The channel stays alive as long as any thread holds it.
Buffers
A Zphp\Buffer is a fixed-length block of bytes. It differs from a string in two ways: its bytes can be changed in place, and it moves to another thread instead of being copied.
When a string is passed to a worker, zphp copies it into the worker and copies the result back, so the cost grows with the size of the data. A buffer's bytes change owner without being copied, so passing one costs about the same at any size. Round trips to a worker and back on an Apple M4 Pro, with a release build:
| Size | String | Buffer |
|---|---|---|
| 64 KB | 0.029 ms | 0.012 ms |
| 1 MB | 0.20 ms | 0.011 ms |
| 16 MB | 3.9 ms | 0.012 ms |
| 256 MB | 73 ms | 0.016 ms |
The buffer times are the cost of submitting and awaiting a task, with no copying. Use a buffer whenever a worker needs large binary data: file contents, images, audio, compressed archives, or network frames.
Moving to another thread
This worker builds an 8 MB binary file, one 64-bit number per entry, and hands it back without a copy:
<?php
$pool = new Zphp\Pool(workers: 1);
$export = $pool->submit(function (int $count) {
$data = new Zphp\Buffer($count * 8);
for ($i = 0; $i < $count; $i++) {
$data->writeInt64LE($i * 8, $i * $i);
}
return $data;
}, [1_000_000]);
$data = $export->await();
echo $data->length(), " bytes\n";
$file = fopen('squares.bin', 'wb');
$data->writeTo($file);
fclose($file);
8000000 bytes
Passing a buffer to a worker, returning one from a task, or sending one on a channel moves its bytes. The thread that sent it keeps the Zphp\Buffer object, but it is detached: isDetached() returns true, and any other method throws Zphp\TransferException.
<?php
$data = Zphp\Buffer::fromString('some bytes');
$pool->submit(fn(Zphp\Buffer $data) => $data->length(), [$data])->await();
var_dump($data->isDetached()); // bool(true)
$data->toString(); // throws Zphp\TransferException
Only one thread can use the bytes at a time, so there are no data races and no locks.
Slices
slice($offset, $length) returns a view of part of a buffer without copying. A slice shares its parent's bytes, so a write through either one is visible in both. Omitting the length extends the slice to the end of the buffer.
<?php
$text = Zphp\Buffer::fromString('hello world');
$first = $text->slice(0, 5);
$first->write(0, 'J');
echo $text->toString(), "\n";
Jello world
A slice always refers to the whole block it was cut from. Moving a slice to another thread moves the entire block and detaches the parent and every other slice of it. To send only part of a large buffer, clone the slice first: clone $buffer->slice(0, 1024) copies those 1024 bytes into a new, independent buffer.
Reading and writing
A PNG file stores the image's width and height as 32-bit big-endian numbers at bytes 16 and 20. This reads them without loading the rest of the file:
<?php
$file = fopen('photo.png', 'rb');
$header = new Zphp\Buffer(24);
$header->readFrom($file);
fclose($file);
echo $header->readUInt32BE(16), ' x ', $header->readUInt32BE(20), "\n";
640 x 480
Writing works the same way. This builds a message with a 4-byte length in front of it, a common format for network protocols:
<?php
$message = '{"user":42}';
$packet = new Zphp\Buffer(4 + strlen($message));
$packet->writeUInt32BE(0, strlen($message));
$packet->write(4, $message);
$length = $packet->readUInt32BE(0);
echo $packet->slice(4, $length)->toString(), "\n";
{"user":42}
| Method | Behavior |
|---|---|
new Zphp\Buffer($length) | A buffer of $length zero bytes |
Zphp\Buffer::fromString($bytes) | A buffer holding a copy of a string |
length() | The buffer's size in bytes |
toString() | A string copy of the bytes |
write($offset, $data) | Copies a string or another buffer in at $offset; overlapping copies are handled |
slice($offset, $length = null) | A view of part of the buffer |
readFrom($stream) | One read from a stream straight into the buffer; returns the bytes read, 0 at end of file, or false if the stream cannot be read |
writeTo($stream) | Writes the buffer to a stream; returns the bytes written, or false if the stream cannot be written |
isDetached() | Whether the bytes moved to another thread |
readFrom and writeTo accept anything fread and fwrite accept, including files, sockets, php://memory, and user stream wrappers. Like fread, one readFrom call can return fewer bytes than the buffer holds, for example from a socket; read into $buffer->slice($read) to continue where the last call stopped.
Fixed-width numbers have a read and a write method for each type:
| Type | Methods |
|---|---|
| 8-bit | readInt8, readUInt8, writeInt8, writeUInt8 |
| 16-bit | readInt16LE, readInt16BE, readUInt16LE, readUInt16BE, and the matching write methods |
| 32-bit | readInt32LE, readInt32BE, readUInt32LE, readUInt32BE, and the matching write methods |
| 64-bit | readInt64LE, readInt64BE, writeInt64LE, writeInt64BE |
| Floats | readFloat32LE, readFloat32BE, readFloat64LE, readFloat64BE, and the matching write methods |
Each read takes an offset. Each write takes an offset and a value. A value outside the type's range, or an offset and width that do not fit inside the buffer, throws ValueError. There is no unsigned 64-bit type, because PHP integers are signed.
A buffer never grows. Allocate the size you need, or use slice to work with the part that is filled.
Copies
clone gives an independent copy of a buffer's bytes. serialize() also copies, so a serialized buffer can be stored or sent over the network and unserialized into a new buffer. Only a transfer between threads moves the bytes.
Bytecode Compilation
zphp build compiles a PHP file to serialized bytecode without executing it:
zphp build app.php
zphp run app.zphpc
For a .php input, the output replaces that suffix with .zphpc in the same directory. For other filenames, .zphpc is appended.
Running the bytecode skips parsing and compiling the entry point. This does not bundle files loaded with include or require, or application assets. Keep those runtime dependencies available at their expected paths.
zphp serve accepts a PHP source entry point and retains compiled bytecode across requests; it does not require a separate build step. A .zphpc file is for zphp run, not zphp serve.
For an executable containing the runtime and entry-point bytecode, see Standalone Executables.
Standalone Executables
zphp build --compile copies the current zphp executable and appends the application to it:
zphp build --compile public/index.php
./index
The executable runs the program in CLI mode, without a separate PHP or zphp installation. Arguments are passed to that program. It does not dispatch serve or turn the application into a standalone HTTP server.
What gets packed
The build packs every file under the project root, not only the entry script. Scripts reached through require, include, Composer's autoloader, or a path built at runtime are all in the executable, and so are templates, configuration files, and other assets. PHP files are compiled to bytecode during the build, so the executable does not parse them again at startup. A file that fails to compile is packed as source and reports its parse error when it is loaded, as it would under PHP.
The project root is the nearest directory above the entry script that contains a composer.json, or the entry script's own directory when there is none. .git and node_modules directories are never packed.
| Option | Effect |
|---|---|
--root DIR | Pack DIR instead of the detected project root. The entry script must be inside it. |
--exclude PATH | Leave out a file or directory, given relative to the root. Repeat it for several paths. |
--out FILE, -o FILE | Write the executable to FILE. By default it is named after the entry script's stem and written in the current directory. The name cannot match a file or directory at the top of the root, since the executable would hide it. |
Exclude files that should not ship inside the binary, such as secrets, local databases, and caches built on the development machine:
zphp build --compile artisan --exclude .env --exclude storage/framework/cache -o shop
Paths at runtime
The packed files appear under the directory that holds the executable, at the same relative paths they had under the project root. If ./app was built from a project containing config/app.php, then __DIR__, realpath(), file_exists(), and include inside the program see that file at <executable directory>/config/app.php.
The executable's directory is layered over the packed files. A file that exists on disk there takes precedence over the packed copy, and directory listings (scandir, glob, opendir, DirectoryIterator) show the names from both. Packed files are never modified. Writes go to the disk:
- Creating or replacing a file writes it next to the executable, creating the directories above it when they exist only in the pack.
- Appending to or editing a packed file, including opening a packed SQLite database, first copies the packed file to the disk and then changes the copy.
- Deleting or renaming a packed file hides the packed copy until the program exits.
A program that writes logs, compiled templates, or a database therefore works without preparing any directories, and whatever it writes persists next to the executable between runs.
Deployment requirements
This command does not cross-compile. Deploy to a compatible operating system and architecture.
The output inherits the installed zphp binary's library dependencies. The build prefers static linking for libraries such as OpenSSL and SQLite, but links other libraries dynamically, including database clients and curl. Dynamic dependencies are required even when the PHP program does not call those extensions. The Linux musl release binaries are fully static, so executables built with them have no library dependencies.
Inspect the resulting executable with ldd ./app on Linux or otool -L ./app on macOS, and install its required libraries on the target machine. Do not assume that the target OS supplies them.
Test Runner
zphp test is a built-in test runner. It discovers test files, runs them, and reports results.
Usage
$ zphp test
This discovers and runs test files recursively in tests/ and test/ directories. Files must be named *_test.php or *Test.php.
To run a specific file:
$ zphp test tests/math_test.php
Writing tests
Define functions prefixed with test_. Each function runs in a fresh VM. When test_ functions are present, top-level setup code is not executed. Put required setup inside each test function, or use a file-level test. If the function completes without error, it passes. If it throws an exception, it fails.
<?php
function test_addition() {
assert(1 + 1 === 2);
}
function test_string_concat() {
$result = "hello" . " " . "world";
assert($result === "hello world");
}
function test_array_push() {
$arr = [1, 2, 3];
$arr[] = 4;
assert(count($arr) === 4);
assert($arr[3] === 4);
}
function test_exception_handling() {
$caught = false;
try {
throw new RuntimeException("test");
} catch (RuntimeException $e) {
$caught = true;
}
assert($caught);
}
$ zphp test tests/math_test.php
pass test_addition
pass test_string_concat
pass test_array_push
pass test_exception_handling
4 tests passed
File-level tests
If a test file has no test_ functions, the entire file is executed as a single test. It passes if it completes without error.
<?php
// tests/smoke_test.php
require __DIR__ . '/../src/app.php';
$result = process_data([1, 2, 3]);
assert($result === 6, "Expected 6, got $result");
Formatter
zphp fmt is a built-in, opinionated PHP code formatter.
Usage
Format files in place. Pass file paths explicitly; the formatter does not walk directories:
$ zphp fmt src/app.php src/utils.php
Check if files are formatted (without modifying them):
$ zphp fmt --check src/app.php
In check mode, exit code 0 means the file is already formatted. Exit code 1 means changes would be made. Read, parse, or write errors return exit code 2.
CI integration
Use --check in your CI pipeline to enforce formatting:
- run: zphp fmt --check src/*.php
Package Manager
zphp includes a package manager that uses Packagist, the same registry that Composer uses. It is not a complete replacement for Composer. Test installed packages with your application.
Quick start
$ zphp add slim/slim
This creates a composer.json, resolves dependencies, downloads packages, and generates a vendor/autoload.php that works with zphp's autoloader.
Commands
| Command | Description |
|---|---|
zphp install | Install all packages from composer.json |
zphp add <package> | Add a package and install it |
zphp remove <package> | Remove a package |
zphp packages | List installed packages |
composer.json
zphp reads require, require-dev, and project PSR-4 mappings from composer.json:
{
"require": {
"slim/slim": "^4.0",
"slim/psr7": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Version constraints
| Constraint | Meaning |
|---|---|
^1.2.3 | >=1.2.3, <2.0.0 |
~1.2.3 | >=1.2.3, <1.3.0 |
>=1.0 | 1.0 or higher |
* | Any version |
1.2.3 | Exact version |
Lock file
zphp install writes resolved versions to zphp.lock, but resolves dependencies again on each install rather than installing from that lock file. It does not provide Composer-style reproducible installs.
The resolver skips php and ext-* requirements. Composer scripts, plugins, and the full Composer dependency-resolution behavior are not implemented. Keep using Composer when your project depends on those features.
Autoloading
The generated vendor/autoload.php supports project and package PSR-4 mappings and package autoload.files entries. It does not implement every Composer autoload mode.
<?php
require __DIR__ . '/vendor/autoload.php';
What Works the Same
zphp implements PHP language features including classes, namespaces, closures, exceptions, generators, enums, attributes, and fibers. It supports array copy-on-write, references, and parameter and return type declarations. Support for a feature does not imply parity in every edge case.
The standard library includes string and array functions, JSON, date/time, file I/O, PCRE2 regular expressions, HTTP functions, sessions, cURL, and PDO drivers for SQLite, MySQL, and PostgreSQL. Check the functions and extensions your application uses before switching runtimes.
References and include scope
References can alias global variables:
<?php
$value = 10;
function modify() {
global $value;
$ref =& $value;
$ref = 20;
}
modify();
echo $value; // 20
require and include execute in the caller's scope. An included file can read and update caller variables, and return a value:
// config.php
<?php
$host = 'localhost';
return ['port' => 3306];
// app.php
<?php
$config = require __DIR__ . '/config.php';
echo $host; // localhost
Memory management
Strings, arrays, objects, generators, fibers, and reference cells use reference counting. A cycle collector reclaims unreachable cycles. This applies within a running script, not only when a request ends.
Test coverage
CI compares PHP script output with PHP 8.5 and runs multi-file examples. Separate jobs exercise Laravel, WordPress, Symfony, Doctrine, Composer, and PHPUnit, along with server behavior and standalone compilation. These harnesses cover specific scenarios, not full framework or package compatibility.
Runtime CI runs on pushes and pull requests to main, excluding changes confined to docs/. See What Works Differently for compatibility cautions.
What Works Differently
zphp is not a drop-in replacement for every PHP application. Test your application and its dependencies against both runtimes, especially when they rely on extension behavior or reflection details.
Timezones
zphp resolves timezone data using a built-in table, system zoneinfo, and an embedded IANA database fallback. timezone_identifiers_list() and timezone_abbreviations_list() are implemented.
The identifier list is fixed in the implementation, and the abbreviation list is derived from the built-in table rather than all historical IANA records. The identifier-list implementation does not apply PHP's group or country filters. Do not assume these listings match the PHP version or timezone database installed on your machine.
Named arguments
User-defined functions support named arguments. Built-in functions use a signature table that covers only part of the standard library; unlisted built-ins fall back to positional arguments. Use positional arguments when a built-in's named-argument behavior has not been verified.
Package tooling
The built-in package manager reads parts of composer.json, but does not implement Composer's full behavior. It skips PHP and extension version constraints, so a successful install does not establish runtime compatibility. Its lock file is not used to pin subsequent installs. See Package Manager.
Compatibility checks
The test coverage is evidence for the scenarios exercised, not a guarantee for arbitrary applications. When reporting a difference, include a small PHP script and the output from both runtimes.
Benchmarks
Build with make release when measuring performance. Debug builds include allocation diagnostics and are not representative of execution speed.
Runtime
The runtime suite measures six compute-heavy scripts, taking the best of five runs for each runtime. Times include process startup and the timing harness overhead; startup is not subtracted.
Results on Apple M4 with PHP 8.5.4, without PHP's just-in-time compiler:
| Benchmark | PHP | zphp | zphp/PHP |
|---|---|---|---|
| string_ops | 99 ms | 37 ms | 0.37x |
| array_ops | 81 ms | 43 ms | 0.53x |
| objects | 103 ms | 76 ms | 0.74x |
| closures | 103 ms | 99 ms | 0.96x |
| fibonacci | 171 ms | 260 ms | 1.52x |
| loops | 132 ms | 209 ms | 1.58x |
A ratio below 1 means zphp took less time. These scripts help detect runtime regressions; they do not predict the performance of a framework application.
make bench
Measure your application's actual workload before choosing a deployment configuration.
Parallel work
A worker pool runs PHP on several cores at once. Splitting 32 CPU-bound tasks took 240 ms on one worker and 32 ms on eight, on an Apple M4 Pro.
Values passed to and from workers are copied, so large strings cost time in proportion to their size: a 16 MB string takes about 3.9 ms to reach a worker and come back. A buffer of any size takes about 0.012 ms, because its bytes move instead of being copied. Pass large binary data to workers as buffers.
tests/workers/run measures both.
HTTP throughput
The HTTP harness uses wrk against a trivial response. Its defaults are four client threads, 100 connections, and ten seconds.
make release
./benchmarks/serve/wrk_bench
It requires wrk and Docker for the nginx and PHP-FPM comparison. Historical measurements used native zphp against linux/amd64 containers under emulation on Apple Silicon. That difference prevents a fair runtime comparison, so those results should not be used as a production speedup claim.
Formatter
The formatter harness compares the best of ten runs on benchmarks/sample.php:
./benchmarks/fmt
It uses GNU-style nanosecond timestamps from date, so run it in a compatible environment, such as Linux. It may install comparison tools. Formatters apply different rules, and this benchmark does not establish equivalent output.
Memory Model
zphp reclaims unused PHP values during execution, rather than waiting for the script or request to finish. This supports long-running command-line programs whose temporary data is no longer needed after each iteration.
Like traditional PHP, zphp uses reference counting and a cycle collector. Traditional PHP also supports long-running workers; request boundaries are not its only mechanism for reclaiming memory.
Reclaiming unused values
Reference counting tracks the holders of a value. Replacing a variable or removing an array entry releases that holder. When the last holder disappears, the runtime can reclaim the value. Cleanup is deferred to safe execution boundaries so temporary values remain valid while an expression is being evaluated.
Heap-backed strings and arrays participate in this ownership model, as do objects, generators, and fibers. Variables joined with & share a reference cell: storage that lets each alias see the same value. When its last holder disappears, the cell releases its value and becomes available for reuse.
Reference counting alone cannot reclaim a cycle, such as an array containing a reference to itself. zphp's cycle collector detects unreachable cycles. Collection runs automatically at allocation thresholds and can also be requested with gc_collect_cycles().
Long-running programs
A loop does not need to end its process to release temporary PHP values. For example, replacing $batch releases the previous batch when nothing else retains it:
for ($i = 0; $i < 100000; $i++) {
$batch = [$i, ['next' => $i + 1]];
processBatch($batch);
unset($batch);
}
This assumes processBatch() does not keep the batch in persistent storage. A growing cache or a list that retains every result will still consume increasing memory. Closures that capture values can keep them alive too.
Reclaimed memory can be reused without being returned immediately to the operating system. Internal buffers also retain capacity. A process's reported memory therefore need not fall after unset() or cycle collection, and reference counting does not guarantee a fixed memory ceiling for every workload.
The regression suite checks repeated reference creation and object replacement, including reference cycles and suspended execution. These checks test bounded memory for those workloads, not every possible application. See tests/reference_cell_lifetime.php, tests/global_reference_cycle_memory.php, and tests/cli_event_loop_memory.php.
Request lifecycle
In serve mode, each worker thread owns a persistent VM instance:
- Before a request, the VM resets and releases the previous request's PHP values.
- Superglobals such as
$_SERVERand$_POSTare populated from the incoming request. - The entry file executes from the top and the response is sent.
Ordinary PHP variables do not carry application state between requests. Request reset remains a cleanup boundary in addition to reclamation during execution.
Compiled bytecode is cached across requests. Required files are compiled when first encountered and re-executed from the cache on subsequent requests. Internal buffers retain capacity for reuse.
Copy-on-write
Like PHP, zphp uses copy-on-write for arrays. Assignment or argument passing can share the underlying data until a write requires separation.
$a = [1, 2, 3];
$b = $a;
$b[0] = 9;
var_dump($a[0]); // int(1)
var_dump($b[0]); // int(9)
Ordinary array assignment preserves independent values without immediately copying the array. Explicit references made with & share storage instead.
Environment variables
Workers capture environment variables at startup. $_ENV is initialized from that snapshot for each request. Restart workers to pick up changes to the process environment.