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> | Production 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 runs standard PHP code. Existing .php files, classes, closures, generators, and standard library functions all work. The compatibility section covers what's supported in detail.
The difference is in the tooling around it. Instead of assembling nginx, php-fpm, composer, phpunit, and php-cs-fixer separately, zphp bundles all of that into one binary.
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 |
| 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.x and a few system libraries.
Ubuntu/Debian:
$ sudo apt-get install -y libpcre2-dev libsqlite3-dev zlib1g-dev \
libmysqlclient-dev libpq-dev libssl-dev libnghttp2-dev
$ zig build -Doptimize=ReleaseFast
$ ./zig-out/bin/zphp --version
macOS (Homebrew):
$ brew install mysql-client libpq openssl@3 nghttp2
$ make build
$ ./zig-out/bin/zphp --version
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
$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
listening on http://0.0.0.0:3000 (14 workers)
$ curl http://localhost:3000/api/hello
{"method":"GET","path":"\/api\/hello","message":"Hello from zphp"}
That's a production HTTP server running from a single command. See Serving an Application for the full details.
Serving an Application
zphp serve is a production HTTP server built into the runtime. It replaces the nginx + php-fpm stack with a single process that handles keep-alive, gzip, static files, worker pooling, and graceful shutdown.
Basic usage
$ zphp serve app.php
listening on http://0.0.0.0:8080 (14 workers)
Your app.php is compiled to bytecode once at startup. Each worker runs its own VM instance with a pooled copy of the bytecode, so there's no per-request compilation overhead.
Options
| Flag | Default | Description |
|---|---|---|
--port <N> | 8080 | Port to listen on |
--workers <N> | CPU count | Number of worker threads |
--tls-cert <file> | - | Path to TLS certificate (enables HTTPS) |
--tls-key <file> | - | Path to TLS private key |
--watch | off | Watch PHP files for changes and automatically reload workers |
$ zphp serve app.php --port 3000 --workers 8
Request handling
Each request executes your PHP file from the top. The standard $_SERVER, $_GET, $_POST, $_COOKIE, and $_FILES superglobals are populated from the incoming request, just like PHP.
<?php
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['REQUEST_URI'];
if ($method === 'GET' && $path === '/health') {
echo json_encode(['status' => 'ok']);
} else if ($method === 'POST' && $path === '/api/data') {
$body = file_get_contents('php://input');
$data = json_decode($body, true);
echo json_encode(['received' => $data]);
} else {
http_response_code(404);
echo json_encode(['error' => 'not found']);
}
Response headers and status codes
The standard PHP header functions work in serve mode:
<?php
header("Content-Type: application/json");
header("X-Custom: value");
header("X-Multi: one");
header("X-Multi: two", false); // append instead of replace
header("Location: /other", true, 302); // set status code as third arg
http_response_code(201);
setcookie("session", "abc123", ["path" => "/", "httponly" => true]);
header_remove("X-Custom"); // remove a specific header
header_remove(); // remove all custom headers
headers_list(); // get array of all set headers
These functions work from any call depth - inside functions, methods, closures, included files.
Features
Gzip compression is applied automatically to compressible responses (text, JSON, SVG) when the client sends Accept-Encoding: gzip.
Keep-alive connections are supported by default. Clients can reuse TCP connections across multiple requests.
ETag and 304 responses are handled automatically for static files. The server generates ETags and responds with 304 Not Modified when the content hasn't changed.
.env auto-loading at startup. If a .env file exists in the working directory, it's loaded automatically and the values are available via $_ENV.
File watching with --watch reloads workers when PHP files change. Useful during development.
Graceful shutdown on SIGTERM/SIGINT. Active requests complete before the server exits.
Comparison to nginx + php-fpm
The traditional PHP deployment requires configuring and running multiple processes:
nginx (reverse proxy, static files, TLS termination)
-> php-fpm (process manager, spawns PHP workers)
-> your PHP code
With zphp:
zphp serve app.php --tls-cert cert.pem --tls-key key.pem
TLS, static files, gzip, and HTTP/2 are all handled by the same process.
TLS and HTTP/2
zphp has built-in TLS support via OpenSSL and HTTP/2 via nghttp2. No reverse proxy needed.
Enabling TLS
Provide a certificate and private key:
$ zphp serve app.php --tls-cert cert.pem --tls-key key.pem
listening on https://0.0.0.0:8080 (14 workers)
This enables HTTPS on the same port. Both the certificate and key flags are required together.
HTTP/2
When TLS is enabled, HTTP/2 is automatically negotiated via ALPN. Clients that support HTTP/2 (all modern browsers) will use it. Clients that don't will fall back to HTTP/1.1.
HTTP/2 features supported:
- Stream multiplexing (multiple requests over a single connection)
- Header compression (HPACK)
- Server-side stream management
No configuration needed. If TLS is on, HTTP/2 is available.
Self-signed certificates for development
For 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'
Then serve with TLS:
$ zphp serve app.php --tls-cert cert.pem --tls-key key.pem --port 8443
$ curl -k https://localhost:8443/
Production TLS
For production, use certificates from Let's Encrypt or your certificate authority. Point --tls-cert at the fullchain certificate and --tls-key at the private key.
You can also run zphp behind a reverse proxy (nginx, Caddy, etc.) that handles TLS termination, and serve plain HTTP from zphp. Both approaches work.
Static Files
zphp serve serves static files automatically from the same directory as your PHP file. No configuration required.
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. If it doesn't exist or is a .php request, your PHP entry point handles the request.
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 sets the correct Content-Type header based on file extension:
| 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
Static files are served with:
- ETag headers based on file content
- Cache-Control: max-age=3600 (1 hour)
- Automatic 304 Not Modified responses when the client sends a matching
If-None-Matchheader
Compression
Text-based static files (HTML, CSS, JS, JSON, SVG) are gzip-compressed automatically when the client supports it.
WebSockets
zphp has built-in WebSocket support. Define three handler functions and you have a WebSocket server.
Handler functions
zphp looks for three globally registered functions by name:
<?php
function ws_onOpen($conn) {
// called when a client connects
$conn->send("welcome");
}
function ws_onMessage($conn, $message) {
// called when a client sends a message
$conn->send("echo: " . $message);
}
function ws_onClose($conn) {
// called when a client disconnects
}
These can be defined anywhere - in your entry point, in a required file, wherever. As long as they're registered as global functions by the time the server starts handling connections, they'll be found.
Serve it:
$ zphp serve ws_app.php --port 8080
Clients connect via ws://localhost:8080 (or wss:// if TLS is enabled). Regular HTTP requests are still handled by your PHP code as normal. WebSocket and HTTP coexist on the same port.
The connection object
Each handler receives a WebSocketConnection object:
| Method | Description |
|---|---|
$conn->send($message) | Send a text message to the client |
$conn->close() | Close the connection |
Example: chat relay
<?php
$clients = [];
function ws_onOpen($conn) {
global $clients;
$clients[] = $conn;
$conn->send("connected (" . count($clients) . " online)");
}
function ws_onMessage($conn, $message) {
global $clients;
foreach ($clients as $client) {
$client->send($message);
}
}
function ws_onClose($conn) {
global $clients;
$clients = array_filter($clients, fn($c) => $c !== $conn);
}
WebSocket with TLS
When TLS is enabled, WebSocket connections automatically upgrade to WSS:
$ zphp serve ws_app.php --tls-cert cert.pem --tls-key key.pem
Clients connect via wss://localhost:8080.
Mixed HTTP and WebSocket
Your entry point handles both regular HTTP requests and WebSocket connections. The WebSocket handler functions are only called for WebSocket upgrade requests. Everything else goes through the normal request path.
<?php
// HTTP requests execute this code
$path = $_SERVER['REQUEST_URI'];
if ($path === '/api/status') {
echo json_encode(['status' => 'running']);
}
// WebSocket connections call these functions
function ws_onOpen($conn) {
$conn->send("connected");
}
function ws_onMessage($conn, $msg) {
$conn->send("got: " . $msg);
}
function ws_onClose($conn) {}
Bytecode Compilation
zphp build compiles a PHP file to bytecode ahead of time. The output is a .zphpc file that can be executed directly, skipping the parsing and compilation step at runtime.
Usage
$ zphp build app.php
This produces app.zphpc in the same directory. Run it with:
$ zphp run app.zphpc
When to use this
For most use cases, zphp run and zphp serve handle compilation transparently and you don't need to think about it. zphp serve compiles your entry point once at startup and reuses the bytecode across all workers and requests.
Pre-compiling to .zphpc is useful when you want to:
- Ship bytecode without source files
- Eliminate any startup compilation overhead in scripting contexts
- Verify that a file compiles successfully without running it
See also
For shipping your PHP application as a single self-contained binary, see Standalone Executables.
Standalone Executables
zphp build --compile produces a single executable that contains both the zphp runtime and your compiled PHP bytecode. The result is a binary that runs without needing zphp or PHP installed on the target machine.
Usage
$ zphp build --compile app.php
This produces an executable called app (the input filename without the extension):
$ ./app
Hello from my PHP application
What this means for deployment
The target machine doesn't need PHP or zphp installed. Copy the binary, run it.
$ scp app server:/usr/local/bin/
$ ssh server '/usr/local/bin/app'
This works for both scripts (zphp run style) and servers (zphp serve style). A compiled server binary includes the full HTTP server, TLS support, and everything else zphp serve provides.
Dependencies
Most of zphp's dependencies are statically linked into the binary. A few remain dynamic:
| Library | Linked | Notes |
|---|---|---|
| pcre2, OpenSSL, nghttp2 | Static | Built into the binary |
| sqlite3, zlib | Dynamic | Present on all Linux and macOS systems |
| libmysqlclient | Dynamic | Only needed if using PDO with MySQL |
| libpq | Dynamic | Only needed if using PDO with PostgreSQL |
If your application doesn't use MySQL or PostgreSQL, the binary runs with no additional libraries beyond what the OS provides. If it does, the respective client library needs to be installed on the target machine:
$ sudo apt-get install -y libmysqlclient21 # MySQL
$ sudo apt-get install -y libpq5 # PostgreSQL
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 all test files 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 is run independently. 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 passed, 0 failed
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:
$ 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.
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. Your existing Composer packages work with zphp.
Quick start
$ zphp add slim/slim
$ zphp install
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 the same composer.json format you're used to:
{
"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 generates a zphp.lock file that pins exact versions. Commit this to version control for reproducible installs.
Autoloading
The generated vendor/autoload.php supports PSR-4 namespace mapping. Use it the same way you would with Composer:
<?php
require 'vendor/autoload.php';
$app = Slim\Factory\AppFactory::create();
$app->get('/', function ($request, $response) {
$response->getBody()->write("Hello");
return $response;
});
$app->run();
What Works the Same
zphp aims to run standard PHP code as-is. The following all work the way you'd expect from PHP:
- Types: strings, integers, floats, booleans, arrays, null, objects
- Control flow: if/else, switch, match, for, foreach, while, do-while
- Functions: named functions, closures, arrow functions, default parameters, variadic arguments, named arguments, pass-by-reference
- Classes: inheritance, interfaces, traits, abstract classes, static methods and properties, visibility modifiers, constructors, magic methods (
__construct,__get,__set,__call,__callStatic,__toString,__invoke,__clone,__isset,__unset) - Namespaces:
usestatements, fully qualified names, aliases - Exceptions: try/catch/finally, custom exception classes, exception chaining
- Generators: yield, yield from, generator return values
- Enums: basic and backed enums, enum methods
- Attributes:
#[Attribute]syntax on classes, methods, properties, and parameters, with full reflection support (getAttributes(),getName(),getArguments(),newInstance()) - Type hints: parameter types, return types, nullable types, union types
- String interpolation:
"Hello, $name"and"Hello, {$obj->name}" - Arrays: both sequential and associative, nested arrays, array destructuring
- Superglobals:
$_SERVER,$_GET,$_POST,$_COOKIE,$_FILES,$_ENV,$_REQUEST,$_SESSION(in serve mode) - Fibers:
Fiber,Fiber::start,Fiber::resume,Fiber::suspend,Fiber::getReturn,Fiber::isRunning,Fiber::isTerminated,Fiber::isSuspended,Fiber::isStarted,Fiber::getCurrent - SPL:
SplStack,SplQueue,SplDoublyLinkedList,SplPriorityQueue,SplFixedArray,SplMinHeap,SplMaxHeap,SplObjectStorage,ArrayObject,ArrayIterator,WeakMap - Sessions:
session_start,session_destroy,session_id,$_SESSION - HTTP functions:
header(),header_remove(),http_response_code(),setcookie(),headers_sent(),headers_list() - Output buffering:
ob_start,ob_get_clean,ob_end_clean,ob_get_contents,ob_get_level - Standard library: string functions, array functions, math functions, JSON, date/time, file I/O, regex (PCRE2), PDO (SQLite, MySQL, PostgreSQL)
Test suite
zphp is validated against PHP 8.4 with 178 compatibility tests and 82 multi-file example projects. Each test runs the same PHP code in both zphp and PHP 8.4, comparing output exactly. A Laravel application (7 harness tests covering Eloquent, Blade, validation, JSON API, middleware, and error handling) is also tested against both runtimes. Standalone executable compilation is verified with 12 additional tests. The test suite runs on every commit.
What Works Differently
zphp is not a drop-in replacement for every PHP program. There are places where behavior differs from PHP 8.4, either by design or as a current limitation. This page documents the ones you're most likely to notice.
Copy-on-assign vs copy-on-write
PHP uses copy-on-write for arrays: assigning an array to a new variable shares the underlying data until one of them is modified. zphp uses copy-on-assign: the array is fully cloned at the point of assignment.
$a = [1, 2, 3];
$b = $a; // PHP: shared until modified. zphp: full copy now.
In practice, this rarely matters. The semantics are identical from your code's perspective - both produce independent copies. The difference is when the copy happens, which can affect memory usage if you're assigning very large arrays without modifying them.
Global variables
The global keyword works for reading and writing:
$counter = 0;
function increment() {
global $counter;
$counter++; // this works in zphp
}
increment();
echo $counter; // 1
What doesn't work is creating a reference alias between a local and a global:
$value = 10;
function modify() {
global $value;
$ref = &$value; // reference aliasing - not supported in zphp
$ref = 20;
}
modify();
echo $value; // PHP: 20. zphp: 10.
Direct reads and writes through the global keyword work. Indirect modification through reference aliases does not.
Pass-by-reference
Pass-by-reference works for simple variables, array element access (string, integer, and variable keys), object property access ($obj->prop), chained property access up to 4 levels ($obj->a->b->c->d), and dynamic property names ($obj->$var).
function modify(&$val) { $val = 'changed'; }
modify($x); // works
modify($arr['key']); // works
modify($obj->prop); // works
modify($obj->a->b); // works
modify($obj->$dynamicProp); // works
Passing an entire array or object by reference and modifying nested keys inside the function also works:
function set_nested(array &$arr) { $arr['a']['b'] = 99; } // works
Combined property + array access also works:
modify($obj->items['key']); // works
Chains deeper than 4 levels silently skip writeback.
Type hint enforcement
Type hints on function parameters and return values are enforced, matching PHP's behavior. One difference: zphp's fast execution path skips type checking for performance. If your code relies on type errors being thrown in deeply nested hot loops, the behavior may differ.
Auto-vivification
In PHP 8.4, assigning to an index on a scalar value throws a TypeError:
$x = "hello";
$x[] = 1; // PHP 8.4: TypeError
In zphp, this silently fails - the assignment is a no-op. Nested auto-vivification of missing keys (creating intermediate arrays) works correctly.
require and include scope
In PHP, require shares the calling scope. Variables defined in the required file are visible in the caller, and vice versa:
// config.php
$db_host = 'localhost';
// app.php
require 'config.php';
echo $db_host; // PHP: 'localhost'
In zphp, require executes in an isolated scope. Functions and classes are registered globally (as in PHP), but local variables don't cross the boundary. The example above would not work - $db_host would be undefined in app.php.
What still works:
// config.php
return ['host' => 'localhost', 'port' => 3306];
// app.php
$config = require 'config.php'; // return values work fine
// helpers.php
function formatDate($ts) { return date('Y-m-d', $ts); }
// app.php
require 'helpers.php';
echo formatDate(time()); // globally registered functions work
Most modern PHP frameworks (Laravel, Symfony, etc.) use return-based config files and autoloaded classes, both of which work correctly.
strtotime
strtotime() supports common formats, relative modifiers ("next Thursday", "+2 days"), ordinal modifiers, and timezone suffixes (UTC, GMT, EST, PST, numeric offsets, RFC 2822). Timezone parsing is recognition only - internal timestamps are always UTC.
Named arguments
Named arguments work for user-defined functions and approximately 80 common built-in functions. Built-in functions not on this list fall back to positional argument passing.
Benchmarks
All benchmarks run on Apple M4 (14 cores), comparing zphp (ReleaseFast) against PHP 8.5 (no JIT). Benchmarks are in the benchmarks/ directory and can be reproduced locally.
Runtime
Six compute-heavy benchmarks. Best of 5 runs. Startup overhead is subtracted to measure pure execution time.
| Benchmark | PHP | zphp | Ratio |
|---|---|---|---|
| string_ops | 97 ms | 27 ms | 0.28x |
| array_ops | 98 ms | 29 ms | 0.30x |
| objects | 97 ms | 36 ms | 0.37x |
| closures | 98 ms | 83 ms | 0.85x |
| loops | 130 ms | 120 ms | 0.92x |
| fibonacci | 161 ms | 157 ms | 0.97x |
zig build -Doptimize=ReleaseFast
./benchmarks/runtime/run
HTTP throughput
Measured with wrk: 4 threads, 100 connections, 10 seconds. All servers return echo "hello".
| Server | req/s | Avg latency |
|---|---|---|
| zphp serve | 92,343 | 1.12 ms |
| nginx + php-fpm (128 workers) | 42,088 | 50.37 ms |
2.2x throughput, 45x lower latency compared to nginx + php-fpm.
zig build -Doptimize=ReleaseFast
./benchmarks/serve/wrk_bench
Note: nginx + php-fpm numbers are from Docker with linux/amd64 emulation on Apple Silicon. Native Linux performance would be better for php-fpm. These numbers are directional, not absolute. Run the benchmarks on your own hardware for numbers relevant to your deployment.
Formatter
Formatting a 416-line PHP file. Best of 10 runs.
| Tool | Time |
|---|---|
| zphp fmt | 5 ms |
| php-cs-fixer (PSR-12) | 92 ms |
| prettier @prettier/plugin-php | 95 ms |
./benchmarks/fmt
Memory Model
zphp manages memory around the request lifecycle. Each request starts with a clean VM, and when the request ends, all allocations from that request are freed in bulk. There is no garbage collector.
Request lifecycle
In serve mode, each worker thread owns a persistent VM instance. A request goes through these steps:
- The VM resets, freeing all values (strings, arrays, objects, generators, fibers) from the previous request
- Superglobals (
$_SERVER,$_GET,$_POST, etc.) are populated from the incoming HTTP request - The PHP file executes from the top
- The response is sent
Compiled bytecode is not freed between requests. It's compiled once at startup and re-executed each time.
Internal buffers are cleared between requests but keep their allocated capacity, so repeated requests reuse memory rather than reallocating.
How values are stored
Primitives (integers, floats, booleans, null) live on a fixed-size value stack and don't require heap allocation.
Strings, arrays, and objects are heap-allocated and tracked in per-type lists on the VM. When a request ends, the VM walks each list and frees everything. Values cannot leak across requests.
Copy-on-assign
PHP uses copy-on-write for arrays: assigning an array to a new variable shares the underlying data until one side is modified. zphp clones the array at the point of assignment instead.
$a = [1, 2, 3, 4, 5];
$b = $a; // zphp: full clone here. PHP: shared until modified.
The observable semantics are identical - both produce independent copies. The difference is when the copy happens. This can affect memory usage if you assign very large arrays without modifying the copy.
Why no garbage collector
PHP's garbage collector handles reference cycles - objects that point to each other and can't be freed by reference counting alone. zphp doesn't need this because every heap-allocated value is tracked in a flat list and freed at the request boundary. Cycles are irrelevant when nothing survives the request.
Environment variables
Environment variables are captured once when each worker thread starts, stored as a pre-built $_ENV array. Subsequent requests reference this snapshot directly. If you change environment variables after the server starts, workers won't see the changes until they're restarted.
Stack and frame limits
The value stack holds 2,048 entries. The call stack supports 2,048 nested frames. Both are fixed at compile time, and exceeding them produces a runtime error.