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

CommandWhat 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 installInstall 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.

PlatformBinary
Linux x86_64zphp-linux-x86_64
Linux ARM64zphp-linux-aarch64
macOS Apple Siliconzphp-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

FlagDefaultDescription
--port <N>8080Port to listen on
--workers <N>CPU countNumber of worker threads
--tls-cert <file>-Path to TLS certificate (enables HTTPS)
--tls-key <file>-Path to TLS private key
--watchoffWatch 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.css serves project/style.css
  • GET /images/logo.png serves project/images/logo.png
  • GET /anything-else executes project/app.php

Supported content types

zphp sets the correct Content-Type header based on file extension:

ExtensionsContent-Type
.html, .htmtext/html
.csstext/css
.js, .mjsapplication/javascript
.jsonapplication/json
.pngimage/png
.jpg, .jpegimage/jpeg
.gifimage/gif
.svgimage/svg+xml
.icoimage/x-icon
.webpimage/webp
.woff, .woff2font/woff, font/woff2
.pdfapplication/pdf
.wasmapplication/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-Match header

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:

MethodDescription
$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:

LibraryLinkedNotes
pcre2, OpenSSL, nghttp2StaticBuilt into the binary
sqlite3, zlibDynamicPresent on all Linux and macOS systems
libmysqlclientDynamicOnly needed if using PDO with MySQL
libpqDynamicOnly 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

CommandDescription
zphp installInstall all packages from composer.json
zphp add <package>Add a package and install it
zphp remove <package>Remove a package
zphp packagesList 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

ConstraintMeaning
^1.2.3>=1.2.3, <2.0.0
~1.2.3>=1.2.3, <1.3.0
>=1.01.0 or higher
*Any version
1.2.3Exact 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: use statements, 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.

BenchmarkPHPzphpRatio
string_ops97 ms27 ms0.28x
array_ops98 ms29 ms0.30x
objects97 ms36 ms0.37x
closures98 ms83 ms0.85x
loops130 ms120 ms0.92x
fibonacci161 ms157 ms0.97x
zig build -Doptimize=ReleaseFast
./benchmarks/runtime/run

HTTP throughput

Measured with wrk: 4 threads, 100 connections, 10 seconds. All servers return echo "hello".

Serverreq/sAvg latency
zphp serve92,3431.12 ms
nginx + php-fpm (128 workers)42,08850.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.

ToolTime
zphp fmt5 ms
php-cs-fixer (PSR-12)92 ms
prettier @prettier/plugin-php95 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:

  1. The VM resets, freeing all values (strings, arrays, objects, generators, fibers) from the previous request
  2. Superglobals ($_SERVER, $_GET, $_POST, etc.) are populated from the incoming HTTP request
  3. The PHP file executes from the top
  4. 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.