Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ext-php-rs is a Rust library containing bindings and abstractions for the PHP extension API, which allows users to build extensions natively in Rust.

Features

  • Easy to use: The built-in macros can abstract away the need to interact with the Zend API, such as Rust-type function parameter abstracting away interacting with Zend values.
  • Lightweight: You don’t have to use the built-in helper macros. It’s possible to write your own glue code around your own functions.
  • Extensible: Implement IntoZval and FromZval for your own custom types, allowing the type to be used as function parameters and return types.

Goals

Our main goal is to make extension development easier.

  • Writing extensions in C can be tedious, and with the Zend APIs limited documentation can be intimidating.
  • Rust’s modern language features and feature-full standard library are big improvements on C.
  • Abstracting away the raw Zend APIs allows extensions to be developed faster and with more confidence.
  • Abstractions also allow us to support future (and potentially past) versions of PHP without significant changes to extension code.

Versioning

ext-php-rs follows semantic versioning, however, no backwards compatibility is guaranteed while we are at major version 0, which is for the foreseeable future. It’s recommended to lock the version at the patch level.

When introducing breaking changes a migration guide will be provided in this guide.

Documentation

Installation

To get started using ext-php-rs you will need both a Rust toolchain and a PHP development environment. We’ll cover each of these below.

Rust toolchain

First, make sure you have rust installed on your system. If you haven’t already done so you can do so by following the instructions here. ext-php-rs runs on both the stable and nightly versions so you can choose whichever one fits you best.

PHP development environment

In order to develop PHP extensions, you’ll need the following installed on your system:

  1. The PHP CLI executable itself
  2. The PHP development headers
  3. The php-config binary

While the easiest way to get started is to use the packages provided by your distribution, we recommend building PHP from source.

NB: To use ext-php-rs you’ll need at least PHP 8.0.

Using a package manager

# Debian and derivatives
apt install php-dev
# Arch Linux
pacman -S php
# Fedora
dnf install php-devel
# Homebrew
brew install php

Compiling PHP from source

Please refer to this PHP internals book chapter for an in-depth guide on how to build PHP from source.

TL;DR; use the following commands to build a minimal development version with debug symbols enabled.

# clone the php-src repository
git clone https://github.com/php/php-src.git
cd php-src
# by default you will be on the master branch, which is the current
# development version. You can check out a stable branch instead:
git checkout PHP-8.1
./buildconf
PREFIX="${HOME}/build/php"
./configure --prefix="${PREFIX}" \
    --enable-debug \
    --disable-all --disable-cgi
make -j "$(nproc)"
make install

The PHP CLI binary should now be located at ${PREFIX}/bin/php and the php-config binary at ${PREFIX}/bin/php-config.

Next steps

Now that we have our development environment in place, let’s go build an extension !

Hello World

Project Setup

We will start by creating a new Rust library crate:

$ cargo new hello_world --lib
$ cd hello_world

Cargo.toml

Let’s set up our crate by adding ext-php-rs as a dependency and setting the crate type to cdylib. Update the Cargo.toml to look something like so:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
ext-php-rs = "*"

[profile.release]
strip = "debuginfo"

.cargo/config.toml

When compiling for Linux and macOS, we do not link directly to PHP, rather PHP will dynamically load the library. We need to tell the linker it’s ok to have undefined symbols (as they will be resolved when loaded by PHP).

On Windows, we also need to switch to using the rust-lld linker.

Microsoft Visual C++’s link.exe is supported, however you may run into issues if your linker is not compatible with the linker used to compile PHP.

We do this by creating a Cargo config file in .cargo/config.toml with the following contents:

[target.'cfg(not(target_os = "windows"))']
rustflags = ["-C", "link-arg=-Wl,-undefined,dynamic_lookup"]

[target.x86_64-pc-windows-msvc]
linker = "rust-lld"

[target.i686-pc-windows-msvc]
linker = "rust-lld"

[target.'cfg(target_env = "musl")']
rustflags = ["-C", "target-feature=-crt-static"]

Writing our extension

src/lib.rs

Let’s actually write the extension code now. We start by importing the ext-php-rs prelude, which contains most of the imports required to make a basic extension. We will then write our basic hello_world function, which will take a string argument for the callers name, and we will return another string. Finally, we write a get_module function which is used by PHP to find out about your module. We must provide the defined function to the given ModuleBuilder and then return the same object.

We also need to enable the abi_vectorcall feature when compiling for Windows (the first line). This is a nightly-only feature so it is recommended to use the #[cfg_attr] macro to not enable the feature on other operating systems.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
pub fn hello_world(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(hello_world))
}
fn main() {}

Building the extension

Now let’s build our extension. This is done through cargo like any other Rust crate.

If you installed php using a package manager in the previous chapter (or if the php and php-config binaries are already in your $PATH), then you can just run

cargo build

If you have multiple PHP versions in your PATH, or your installation resides in a custom location, you can use the following environment variables:

# explicitly specifies the path to the PHP executable:
export PHP=/path/to/php
# explicitly specifies the path to the php-config executable:
export PHP_CONFIG=/path/to/php-config

As an alternative, if you compiled PHP from source and installed it under it’s own prefix (configure --prefix=/my/prefix), you can just put this prefix in front of your PATH:

export PATH="/my/prefix:${PATH}"

Once you’ve setup these variables, you can just run

cargo build

Cargo will track changes to these environment variables and rebuild the library accordingly.

Testing our extension

The extension we just built is stored inside the cargo target directory: target/debug if you did a debug build, target/release for release builds.

The extension file name is OS-dependent. The naming works as follows:

  • let S be the empty string
  • append to S the value of std::env::consts::DLL_PREFIX (empty on windows, lib on unixes)
  • append to S the lower-snake-case version of your crate name
  • append to S the value of std::env::consts::DLL_SUFFIX (.dll on windows, .dylib on macOS, .so on other unixes).
  • set the filename to the value of S

Which in our case would give us:

  • linux: libhello_world.so
  • macOS: libhello_world.dylib
  • windows: hello_world.dll

Now we need a way to tell the PHP CLI binary to load our extension. There are several ways to do that. For now we’ll simply pass the -d extension=/path/to/extension option to the PHP CLI binary.

Let’s make a test script:

test.php

<?php

var_dump(hello_world("David"));

And run it:

$ php -d extension=./target/debug/libhello_world.so test.php
string(13) "Hello, David!"

cargo php

ext-php-rs comes with a cargo subcommand called cargo-php. When called in the manifest directory of an extension, it allows you to do the following:

  • Generate IDE stub files
  • Install the extension
  • Remove the extension

System Requirements

The subcommand has been tested on the following systems and architectures. Note these are not requirements, but simply platforms that the application have been tested on. YMMV.

  • macOS 12.0 (AArch64, x86_64 builds but untested)
  • Linux 5.15.1 (AArch64, x86_64 builds but untested)

Windows is not currently supported by ext-php-rs.

macOS Note

When installing your extension multiple times without uninstalling on macOS, you may run into PHP exiting with SIGKILL. You can see the exact cause of the exit in Console, however, generally this is due to a invalid code signature. Uninstalling the extension and then reinstalling generally fixes this problem.

Installation

The subcommand is installed with Cargo like any other Rust CLI application:

$ cargo install cargo-php --locked

Installing does not require PHP: cargo-php only depends on ext-php-rs-introspection, the crate that describes an extension for stub generation. PHP and php-config are needed at runtime by the install and remove subcommands, which copy the extension into your PHP installation.

You can then call the application via cargo php (assuming the cargo installation directory is in your PATH):

$ cargo php --help
Installs extensions and generates stub files for PHP extensions generated with `ext-php-rs`.

Usage: cargo-php <COMMAND>

Commands:
  install      Installs the extension in the current PHP installation
  remove       Removes the extension in the current PHP installation
  stubs        Generates stub PHP files for the extension
  static-glue  Generates the C glue required to statically link the extension into php-src
  help         Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

cargo php --version prints the ext-php-rs-introspection version the CLI was built with. Stub generation refuses an extension whose version of that crate is not semver compatible, because the #[repr(C)] layout it reads can have changed. Every layout change of that crate bumps the version that this check compares, so rebuild cargo-php or the extension until both versions match.

The command should always be executed from within your extensions manifest directory (the directory with your Cargo.toml).

Stubs

Stub files are used by your IDEs language server to know the signature of methods, classes and constants in your PHP extension, similar to how a C header file works.

One of the largest collection of PHP standard library and non-standard extension stub files is provided by JetBrains: phpstorm-stubs. This collection is used by JetBrains PhpStorm and the PHP Intelephense language server (which I personally recommend for use in Visual Studio Code).

Usage

$ cargo php stubs --help
cargo-php-stubs

Generates stub PHP files for the extension.

These stub files can be used in IDEs to provide typehinting for extension classes, functions and
constants.

USAGE:
    cargo-php stubs [OPTIONS] [EXT]

ARGS:
    <EXT>
            Path to extension to generate stubs for. Defaults for searching the directory the
            executable is located in

OPTIONS:
    -h, --help
            Print help information

        --manifest <MANIFEST>
            Path to the Cargo manifest of the extension. Defaults to the manifest in the directory
            the command is called.

            This cannot be provided alongside the `ext` option, as that option provides a direct
            path to the extension shared library.

    -o, --out <OUT>
            Path used to store generated stub file. Defaults to writing to `<ext-name>.stubs.php` in
            the current directory

        --stdout
            Print stubs to stdout rather than write to file. Cannot be used with `out`

Custom Allocators

If your extension uses a custom global allocator (e.g., mimalloc, jemalloc), you may encounter a crash when running cargo php stubs with an error like “pointer being freed was not allocated”.

This happens because cargo php stubs builds your extension in debug mode and loads it to extract type information. Custom allocators can conflict with this process.

Workaround: Conditionally enable your custom allocator only in release builds:

#[cfg(not(debug_assertions))]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

See issue #523 for more details.

Extension Installation

When PHP is in your PATH, the application can automatically build and copy your extension into PHP. This requires php-config to be installed alongside PHP.

It is recommended to backup your php.ini before installing the extension so you are able to restore if you run into any issues.

Usage

$ cargo php install --help
cargo-php-install

Installs the extension in the current PHP installation.

This copies the extension to the PHP installation and adds the extension to a PHP configuration
file.

Note that this uses the `php-config` executable installed alongside PHP to locate your `php.ini`
file and extension directory. If you want to use a different `php-config`, the application will read
the `PHP_CONFIG` variable (if it is set), and will use this as the path to the executable instead.

USAGE:
    cargo-php install [OPTIONS]

OPTIONS:
        --disable
            Installs the extension but doesn't enable the extension in the `php.ini` file

    -h, --help
            Print help information

        --ini-path <INI_PATH>
            Path to the `php.ini` file to update with the new extension

        --install-dir <INSTALL_DIR>
            Changes the path that the extension is copied to. This will not activate the extension
            unless `ini_path` is also passed

        --manifest <MANIFEST>
            Path to the Cargo manifest of the extension. Defaults to the manifest in the directory
            the command is called

        --release
            Whether to install the release version of the extension

        --yes
            Bypasses the confirmation prompt

Extension Removal

Removes the extension from your PHPs extension directory, and removes the entry from your php.ini if present.

Usage

$ cargo php remove --help
cargo-php-remove

Removes the extension in the current PHP installation.

This deletes the extension from the PHP installation and also removes it from the main PHP
configuration file.

Note that this uses the `php-config` executable installed alongside PHP to locate your `php.ini`
file and extension directory. If you want to use a different `php-config`, the application will read
the `PHP_CONFIG` variable (if it is set), and will use this as the path to the executable instead.

USAGE:
    cargo-php remove [OPTIONS]

OPTIONS:
    -h, --help
            Print help information

        --ini-path <INI_PATH>
            Path to the `php.ini` file to remove the extension from

        --install-dir <INSTALL_DIR>
            Changes the path that the extension will be removed from. This will not remove the
            extension from a configuration file unless `ini_path` is also passed

        --manifest <MANIFEST>
            Path to the Cargo manifest of the extension. Defaults to the manifest in the directory
            the command is called

        --yes
            Bypasses the confirmation prompt

Types

In PHP, data is stored in containers called zvals (zend values). Internally, these are effectively tagged unions (enums in Rust) without the safety that Rust introduces. Passing data between Rust and PHP requires the data to become a zval. This is done through two traits: FromZval and IntoZval. These traits have been implemented on most regular Rust types:

  • Primitive integers (i8, i16, i32, i64, u8, u16, u32, u64, usize, isize).
  • Double and single-precision floating point numbers (f32, f64).
  • Booleans.
  • Strings (String and &str)
  • Vec<T> where T implements IntoZval and/or FromZval.
  • HashMap<String, T> where T implements IntoZval and/or FromZval.
  • Binary<T> where T implements Pack, used for transferring binary string data.
  • BinarySlice<T> where T implements Pack, used for exposing PHP binary strings as read-only slices.
  • A PHP callable closure or function wrapped with Callable.
  • Option<T> where T implements IntoZval and/or FromZval, and where None is converted to a PHP null.

Return types can also include:

  • Any class type which implements RegisteredClass (i.e. any struct you have registered with PHP).
  • An immutable reference to self when used in a method, through the ClassRef type.
  • A Rust closure wrapped with Closure.
  • Result<T, E>, where T: IntoZval and E: Into<PhpException>. When the error variant is encountered, it is converted into a PhpException and thrown as an exception.

For a type to be returnable, it must implement IntoZval, while for it to be valid as a parameter, it must implement FromZval.

Primitive Numbers

Primitive integers include i8, i16, i32, i64, u8, u16, u32, u64, isize, usize, f32 and f64.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoi32 on 32-bit platforms, i64 on 64-bit platforms, f64 platform-independent

Note that internally, PHP treats all of these integers the same (a ‘long’), and therefore it must be converted into a long to be stored inside the zval. A long is always signed, and the size will be 32-bits on 32-bit platforms and 64-bits on 64-bit platforms.

Floating point numbers are always stored in a double type (f64), regardless of platform. Note that converting a zval into a f32 will lose accuracy. An f32 or f64 parameter also accepts a PHP int, the same as a PHP float parameter.

This means that converting i64, u32, u64, isize and usize can fail depending on the value and the platform, which is why all zval conversions are fallible.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn test_numbers(a: i32, b: u32, c: f32) -> u8 {
    println!("a {} b {} c {}", a, b, c);
    0
}
fn main() {}

PHP example

<?php

test_numbers(5, 10, 12.5); // a 5 b 10 c 12.5

String

When a String type is encountered, the zend string content is copied to/from a Rust String object. If the zval does not contain a string, it will attempt to read a double from the zval and convert it into a String object.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNozend_string (C-string)

Internally, PHP stores strings in zend_string objects, which is a refcounted C struct containing the string length with the content of the string appended to the end of the struct based on how long the string is. Since the string is NUL-terminated, you cannot have any NUL bytes in your string, and an error will be thrown if one is encountered while converting a String to a zval.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn str_example(input: String) -> String {
    format!("Hello {}", input)
}
fn main() {}

PHP example

<?php

var_dump(str_example("World")); // string(11) "Hello World"
var_dump(str_example(5)); // string(7) "Hello 5"

&str

A borrowed string. When this type is encountered, you are given a reference to the actual zend string memory, rather than copying the contents like if you were taking an owned String argument.

T parameter&T parameterT Return type&T Return typePHP representation
NoYesNoYeszend_string (C-string)

Note that you cannot expect the function to operate the same by swapping out String and &str - since the zend string memory is read directly, this library does not attempt to parse double types as strings.

See the String for a deeper dive into the internal structure of PHP strings.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn str_example(input: &str) -> String {
    format!("Hello {}", input)
}

#[php_function]
pub fn str_return_example() -> &'static str {
    "Hello from Rust"
}
fn main() {}

PHP example

<?php

var_dump(str_example("World")); // string(11) "Hello World"
var_dump(str_example(5)); // Invalid

var_dump(str_return_example());

bool

A boolean. Not much else to say here.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoUnion flag

Booleans are not actually stored inside the zval. Instead, they are treated as two different union types (the zval can be in a true or false state). An equivalent structure in Rust would look like:

enum Zval {
    True,
    False,
    String(&mut ZendString),
    Long(i64),
    // ...
}

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn test_bool(input: bool) -> String {
    if input {
        "Yes!".into()
    } else {
        "No!".into()
    }
}
fn main() {}

PHP example

<?php

var_dump(test_bool(true)); // string(4) "Yes!"
var_dump(test_bool(false)); // string(3) "No!"

Rust example, taking by reference

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types;
#[php_function]
pub fn test_bool(input: &mut types::Zval) {
    input.reference_mut().unwrap().set_bool(false);
}
fn main() {}

Vec

Vectors can contain any type that can be represented as a zval. Note that the data contained in the array will be copied into Rust types and stored inside the vector. The internal representation of a PHP array is discussed below.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Internally, PHP arrays are hash tables where the key can be an unsigned long or a string. Zvals are contained inside arrays therefore the data does not have to contain only one type.

When converting into a vector, all values are converted from zvals into the given generic type. If any of the conversions fail, the whole conversion will fail.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn test_vec(vec: Vec<String>) -> String {
    vec.join(" ")
}
fn main() {}

PHP example

<?php

var_dump(test_vec(['hello', 'world', 5])); // string(13) "hello world 5"

HashSet

HashSet is an unordered collection of unique values. When converting to a PHP array, values are stored with sequential integer keys (0, 1, 2, …).

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use std::collections::HashSet;

#[php_function]
pub fn test_hashset(set: HashSet<String>) -> HashSet<String> {
    // Duplicates are automatically removed
    set
}
fn main() {}

PHP example

<?php

// Duplicates are removed, order is not preserved
$result = test_hashset(['a', 'b', 'a', 'c']);
var_dump($result); // array with 3 unique values

BTreeSet

BTreeSet is a sorted collection of unique values. Elements are ordered by their Ord implementation. When converting to a PHP array, values are stored with sequential integer keys (0, 1, 2, …) in sorted order.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use std::collections::BTreeSet;

#[php_function]
pub fn test_btreeset(set: BTreeSet<String>) -> BTreeSet<String> {
    // Duplicates removed, values sorted
    set
}
fn main() {}

PHP example

<?php

// Values are sorted and deduplicated
$result = test_btreeset(['z', 'a', 'm', 'a']);
foreach ($result as $value) {
    echo "$value\n";
}
// a
// m
// z

IndexSet

IndexSet is like HashSet but preserves insertion order. This requires the indexmap feature.

[dependencies]
ext-php-rs = { version = "...", features = ["indexmap"] }
T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Rust example

use ext_php_rs::prelude::*;
use indexmap::IndexSet;

#[php_function]
pub fn test_indexset(set: IndexSet<String>) -> IndexSet<String> {
    // Order is preserved, duplicates removed
    for v in set.iter() {
        println!("v: {}", v);
    }
    set
}

PHP example

<?php

// Order is preserved, duplicates removed
$result = test_indexset(['z', 'a', 'm', 'a']);
foreach ($result as $value) {
    echo "$value\n";
}
// z
// a
// m

HashMap

HashMaps are represented as associative arrays in PHP.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Converting from a zval to a HashMap is valid when the key is a String, and the value implements FromZval. The key and values are copied into Rust types before being inserted into the HashMap. If one of the key-value pairs has a numeric key, the key is represented as a string before being inserted.

Converting from a HashMap to a zval is valid when the key implements AsRef<str>, and the value implements IntoZval.

When using `HashMap` the order of the elements is not preserved.

HashMaps are unordered collections, so the order of elements may not be the same
when converting from PHP to Rust and back.

If you need to preserve the order of elements, consider using `IndexMap` (requires
the `indexmap` feature) or `Vec<(K, V)>`.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use std::collections::HashMap;
#[php_function]
pub fn test_hashmap(hm: HashMap<String, String>) -> Vec<String> {
    for (k, v) in hm.iter() {
        println!("k: {} v: {}", k, v);
    }

    hm.into_iter()
        .map(|(_, v)| v)
        .collect::<Vec<_>>()
}
fn main() {}

PHP example

<?php

var_dump(test_hashmap([
    'hello' => 'world',
    'rust' => 'php',
    'okk',
]));

Output:

k: rust v: php
k: hello v: world
k: 0 v: okk
array(3) {
    [0] => string(3) "php",
    [1] => string(5) "world",
    [2] => string(3) "okk"
}

IndexMap (Order-Preserving)

IndexMap is like HashMap but preserves insertion order, making it ideal for working with PHP arrays where key order matters. This requires the indexmap feature.

[dependencies]
ext-php-rs = { version = "...", features = ["indexmap"] }
T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoZendHashTable

Rust example

use ext_php_rs::prelude::*;
use indexmap::IndexMap;

#[php_function]
pub fn test_indexmap(map: IndexMap<String, String>) -> IndexMap<String, i32> {
    // Order is preserved when iterating
    for (k, v) in map.iter() {
        println!("k: {} v: {}", k, v);
    }

    // Return an ordered map
    let mut result = IndexMap::new();
    result.insert("first".to_string(), 1);
    result.insert("second".to_string(), 2);
    result.insert("third".to_string(), 3);
    result  // Order preserved: first, second, third
}

PHP example

<?php

// Keys maintain their order
$result = test_indexmap([
    'z' => 'last',
    'a' => 'first',
    'm' => 'middle',
]);

// Output preserves insertion order
foreach ($result as $key => $value) {
    echo "$key => $value\n";
}
// first => 1
// second => 2
// third => 3

Vec<(K, V)> and Vec<ArrayKey, V>

Vec<(K, V)> and Vec<ArrayKey, V> are used to represent associative arrays in PHP where the keys can be strings or integers.

If using String or &str as the key type, only string keys will be accepted.

For i64 keys, string keys are only accepted if PHP would treat them as integer keys, e.g. "42" or "-1" but not "+1" or "01".

If you need to accept both string and integer keys, use ArrayKey as the key type.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types::ArrayKey;
#[php_function]
pub fn test_vec_kv(vec: Vec<(String, String)>) -> Vec<String> {
    for (k, v) in vec.iter() {
        println!("k: {} v: {}", k, v);
    }

    vec.into_iter()
        .map(|(_, v)| v)
        .collect::<Vec<_>>()
}

#[php_function]
pub fn test_vec_arraykey(vec: Vec<(ArrayKey, String)>) -> Vec<String> {
    for (k, v) in vec.iter() {
        println!("k: {} v: {}", k, v);
    }

    vec.into_iter()
        .map(|(_, v)| v)
        .collect::<Vec<_>>()
}
fn main() {}

PHP example

<?php

declare(strict_types=1);

var_dump(test_vec_kv([
    ['hello', 'world'],
    ['rust', 'php'],
    ['okk', 'okk'],
]));

var_dump(test_vec_arraykey([
    ['hello', 'world'],
    [1, 'php'],
    ["2", 'okk'],
]));

Output:

k: hello v: world
k: rust v: php
k: okk v: okk
array(3) {
    [0] => string(5) "world",
    [1] => string(3) "php",
    [2] => string(3) "okk"
}
k: hello v: world
k: 1 v: php
k: 2 v: okk
array(3) {
    [0] => string(5) "world",
    [1] => string(3) "php",
    [2] => string(3) "okk"
}

ZendHashTable

ZendHashTable is the internal representation of PHP arrays. While you can use Vec and HashMap for most use cases (which are converted to/from ZendHashTable automatically), working directly with ZendHashTable gives you more control and avoids copying data when you need to manipulate PHP arrays in-place.

When to use ZendHashTable directly

  • When you need to modify a PHP array in place without copying
  • When working with arrays passed by reference
  • When you need fine-grained control over array operations
  • When implementing custom iterators or data structures

Basic Operations

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types::{ZendHashTable, ZendStr};
use ext_php_rs::boxed::ZBox;

#[php_function]
pub fn create_array() -> ZBox<ZendHashTable> {
    let mut ht = ZendHashTable::new();

    // Push values (auto-incrementing numeric keys)
    ht.push("first").unwrap();
    ht.push("second").unwrap();

    // Insert with string keys
    ht.insert("name", "John").unwrap();
    ht.insert("age", 30i64).unwrap();

    // Insert at specific numeric index
    ht.insert_at_index(100, "at index 100").unwrap();
    
    // Insert with ZendStr keys
    // This allows bypassing repeated zend_string_init allocations and re-hashing
    // when working with pre-existing or interned PHP strings
    let key = ZendStr::new("hello", false);
    ht.insert(&key, "world");

    ht
}

#[php_function]
pub fn read_array(arr: &ZendHashTable) {
    // Get by string key
    if let Some(name) = arr.get("name") {
        println!("Name: {:?}", name.str());
    }

    // Get by numeric index
    if let Some(first) = arr.get_index(0) {
        println!("First: {:?}", first.str());
    }

    // Check length
    println!("Length: {}", arr.len());
    println!("Is empty: {}", arr.is_empty());

    // Iterate over key-value pairs
    for (key, value) in arr.iter() {
        println!("{}: {:?}", key, value);
    }
}
fn main() {}

Entry API

The Entry API provides an ergonomic way to handle hash table operations where you need to conditionally insert or update values based on whether a key already exists. This is similar to Rust’s std::collections::hash_map::Entry API.

Basic Usage

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types::ZendHashTable;
use ext_php_rs::boxed::ZBox;

#[php_function]
pub fn entry_example() -> ZBox<ZendHashTable> {
    let mut ht = ZendHashTable::new();

    // Insert a default value if the key doesn't exist
    ht.entry("counter").or_insert(0i64).unwrap();

    // Modify the value if it exists, using and_modify
    ht.entry("counter")
        .and_modify(|v| {
            if let Some(n) = v.long() {
                v.set_long(n + 1);
            }
        })
        .or_insert(0i64)
        .unwrap();

    // Use or_insert_with for lazy initialization
    ht.entry("computed")
        .or_insert_with(|| "computed value")
        .unwrap();

    // Works with numeric keys too
    ht.entry(42i64).or_insert("value at index 42").unwrap();

    ht
}
fn main() {}

Entry Variants

The entry() method returns an Entry enum with two variants:

  • Entry::Occupied - The key exists in the hash table
  • Entry::Vacant - The key does not exist
#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types::{ZendHashTable, Entry, Zval};
use ext_php_rs::boxed::ZBox;

#[php_function]
pub fn match_entry() -> ZBox<ZendHashTable> {
    let mut ht = ZendHashTable::new();
    ht.insert("existing", "value").unwrap();

    // Pattern match on the entry
    match ht.entry("existing") {
        Entry::Occupied(entry) => {
            println!("Key {:?} exists with value {:?}",
                     entry.key(),
                     entry.get().and_then(Zval::str),
             );
        }
        Entry::Vacant(entry) => {
            println!("Key {:?} is vacant", entry.key());
            entry.insert("new value").unwrap();
        }
    }

    ht
}
fn main() {}

Common Patterns

Counting occurrences

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::types::ZendHashTable;
use ext_php_rs::boxed::ZBox;

#[php_function]
pub fn count_words(words: Vec<String>) -> ZBox<ZendHashTable> {
    let mut counts = ZendHashTable::new();

    for word in words {
        counts.entry(word.as_str())
            .and_modify(|v| {
                if let Some(n) = v.long() {
                    v.set_long(n + 1);
                }
            })
            .or_insert(1i64)
            .unwrap();
    }

    counts
}
fn main() {}

Caching computed values

This example demonstrates using or_insert_with_key for lazy computation:

extern crate ext_php_rs;
use ext_php_rs::types::ZendHashTable;

fn expensive_computation(key: &str) -> String {
    format!("computed_{}", key)
}

fn get_or_compute(cache: &mut ZendHashTable, key: &str) -> String {
    let value = cache.entry(key)
        .or_insert_with_key(|k| expensive_computation(&k.to_string()))
        .unwrap();

    value.str().unwrap_or_default().to_string()
}
fn main() {}

Updating existing values

This example shows how to conditionally update a value only if the key exists:

extern crate ext_php_rs;
use ext_php_rs::types::{ZendHashTable, Entry};

fn update_if_exists(ht: &mut ZendHashTable, key: &str, new_value: &str) -> bool {
    match ht.entry(key) {
        Entry::Occupied(mut entry) => {
            entry.insert(new_value).unwrap();
            true
        }
        Entry::Vacant(_) => false,
    }
}
fn main() {}

Entry Methods Reference

Entry methods

MethodDescription
or_insert(default)Insert default if vacant, return &mut Zval
or_insert_with(f)Insert result of f() if vacant
or_insert_with_key(f)Insert result of f(&key) if vacant
or_default()Insert default Zval (null) if vacant
key()Get reference to the key
and_modify(f)Modify value in place if occupied

OccupiedEntry methods

MethodDescription
key()Get reference to key
get()Get reference to value
get_mut()Get mutable reference to value
into_mut()Convert to mutable reference with entry’s lifetime
insert(value)Replace value, returning old value
remove()Remove and return value
remove_entry()Remove and return key-value pair

VacantEntry methods

MethodDescription
key()Get reference to key
into_key()Take ownership of key
insert(value)Insert value and return &mut Zval

PHP Example

<?php

// Using the create_array function
$arr = create_array();
var_dump($arr);
// array(5) {
//   [0]=> string(5) "first"
//   [1]=> string(6) "second"
//   ["name"]=> string(4) "John"
//   ["age"]=> int(30)
//   [100]=> string(12) "at index 100"
// }

// Count words
$counts = count_words(['apple', 'banana', 'apple', 'cherry', 'banana', 'apple']);
var_dump($counts);
// array(3) {
//   ["apple"]=> int(3)
//   ["banana"]=> int(2)
//   ["cherry"]=> int(1)
// }

Binary

Binary data is represented as a string in PHP. The most common source of this data is from the pack and unpack functions. It allows you to transfer arbitrary binary data between Rust and PHP.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNozend_string

The binary type is represented as a string in PHP. Although not encoded, the data is converted into an array and then the pointer to the data is set as the string pointer, with the length of the array being the length of the string.

Binary<T> is valid when T implements Pack. This is currently implemented on most primitive numbers (i8, i16, i32, i64, u8, u16, u32, u64, isize, usize, f32, f64).

Rust Usage

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::binary::Binary;

#[php_function]
pub fn test_binary(input: Binary<u32>) -> Binary<u32> {
    for i in input.iter() {
        println!("{}", i);
    }

    vec![5, 4, 3, 2, 1]
        .into_iter()
        .collect::<Binary<_>>()
}
fn main() {}

PHP Usage

<?php

$data = pack('L*', 1, 2, 3, 4, 5);
$output = unpack('L*', test_binary($data));
var_dump($output); // array(5) { [0] => 5, [1] => 4, [2] => 3, [3] => 2, [4] => 1 }

Binary Slices

Binary data is represented as a string in PHP. The most common source of this data is from the pack and unpack functions. It allows you to use a PHP string as a read-only slice in Rust.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoNoNozend_string

The binary type is represented as a string in PHP. Although not encoded, the data is converted into a slice and then the pointer to the data is set as the string pointer, with the length of the array being the length of the string.

BinarySlice<T> is valid when T implements PackSlice. This is currently implemented on most primitive numbers (i8, i16, i32, i64, u8, u16, u32, u64, isize, usize, f32, f64).

The conversion fails (the argument is rejected) when the string length is not a whole multiple of size_of::<T>() or when the string bytes are not aligned for T. Zend allocates strings on 8-byte boundaries, so the alignment check only fails for exotic allocators. PackSlice::unpack_into itself is unsafe; use Zval::binary_slice, which performs both checks.

Rust Usage

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::binary_slice::BinarySlice;

#[php_function]
pub fn test_binary_slice(input: BinarySlice<u8>) -> u8 {
    let mut sum = 0;
    for i in input.iter() {
        sum += i;
    }

    sum
}
fn main() {}

PHP Usage

<?php

$data = pack('C*', 1, 2, 3, 4, 5);
$output = test_binary_slice($data);
var_dump($output); // 15

Option<T>

Options are used for optional and nullable parameters, as well as null returns. It is valid to be converted to/from a zval as long as the underlying T generic is also able to be converted to/from a zval.

T parameter&T parameterT Return type&T Return typePHP representation
YesNoYesNoDepends on T, null for None.

An Option<T> parameter is nullable. If the caller passes null, the function receives None. If the caller passes a value that T does not accept, the call throws a TypeError, the same as for a T parameter. If T accepts null, as &Zval does, a null argument gives Some.

A trailing Option<T> parameter is also optional. If the caller omits it, the function receives None, and PHP reflection reports a default of null. The macro reads this from FromZvalMut::NULLABLE, so a type alias or a qualified path behaves the same way as Option<T>.

Returning Option<T> is a nullable return type. Returning None will return null to PHP.

Rust example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
pub fn test_option_null(input: Option<String>) -> Option<String> {
    input.map(|input| format!("Hello {}", input).into())
}
fn main() {}

PHP example

<?php

var_dump(test_option_null("World")); // string(11) "Hello World"
var_dump(test_option_null()); // null

Object

An object is any object type in PHP. This can include a PHP class and PHP stdClass. A Rust struct registered as a PHP class is a class object, which contains an object.

Objects are valid as parameters but only as an immutable or mutable reference. You cannot take ownership of an object as objects are reference counted, and multiple zvals can point to the same object. You can return a boxed owned object.

T parameter&T parameterT Return type&T Return typePHP representation
NoYesZBox<ZendObject>Yes, mutable onlyZend object.

Examples

Calling a method

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendObject};

// Take an object reference and also return it.
#[php_function]
pub fn take_obj(obj: &mut ZendObject) -> () {
    let _ = obj.try_call_method("hello", vec![&"arg1", &"arg2"]);
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(take_obj))
}
fn main() {}

Taking an object reference

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendObject};

// Take an object reference and also return it.
#[php_function]
pub fn take_obj(obj: &mut ZendObject) -> &mut ZendObject {
    let _ = obj.set_property("hello", 5);
    dbg!(obj)
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(take_obj))
}
fn main() {}

Creating a new object

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendObject, boxed::ZBox};

// Create a new `stdClass` and return it.
#[php_function]
pub fn make_object() -> ZBox<ZendObject> {
    let mut obj = ZendObject::new_stdclass();
    let _ = obj.set_property("hello", 5);
    obj
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(make_object))
}
fn main() {}

Lazy Objects (PHP 8.4+)

PHP 8.4 introduced lazy objects, which defer their initialization until their properties are first accessed. ext-php-rs provides APIs to introspect and create lazy objects from Rust.

Lazy Object Types

There are two types of lazy objects:

  • Lazy Ghosts: The ghost object itself becomes the real instance when initialized. After initialization, the ghost is indistinguishable from a regular object.

  • Lazy Proxies: A proxy wraps a real instance that is created when first accessed. The proxy and real instance have different identities. After initialization, the proxy still reports as lazy.

Introspection APIs

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendObject};

#[php_function]
pub fn check_lazy(obj: &ZendObject) -> String {
    if obj.is_lazy() {
        if obj.is_lazy_ghost() {
            if obj.is_lazy_initialized() {
                "Initialized lazy ghost".into()
            } else {
                "Uninitialized lazy ghost".into()
            }
        } else if obj.is_lazy_proxy() {
            if obj.is_lazy_initialized() {
                "Initialized lazy proxy".into()
            } else {
                "Uninitialized lazy proxy".into()
            }
        } else {
            "Unknown lazy type".into()
        }
    } else {
        "Not a lazy object".into()
    }
}
fn main() {}

Available introspection methods:

MethodDescription
is_lazy()Returns true if the object is lazy (ghost or proxy)
is_lazy_ghost()Returns true if the object is a lazy ghost
is_lazy_proxy()Returns true if the object is a lazy proxy
is_lazy_initialized()Returns true if the lazy object has been initialized
lazy_init()Triggers initialization of a lazy object
lazy_get_instance()For proxies, returns Option<&mut Self> with the real instance after initialization

You can also check if a class supports lazy objects using ClassEntry::can_be_lazy():

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, zend::ClassEntry};

#[php_function]
pub fn can_class_be_lazy(class_name: &str) -> bool {
    ClassEntry::try_find(class_name)
        .map(|ce| ce.can_be_lazy())
        .unwrap_or(false)
}
fn main() {}

Creating Lazy Objects from Rust

You can create lazy objects from Rust using make_lazy_ghost() and make_lazy_proxy(). These methods require the closure feature:

[dependencies]
ext-php-rs = { version = "0.15", features = ["closure"] }
#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendObject, boxed::ZBox};

#[php_function]
pub fn create_lazy_ghost(obj: &mut ZendObject) -> PhpResult<()> {
    let init_value = "initialized".to_string();
    obj.make_lazy_ghost(Box::new(move || {
        // Initialization logic - use captured state
        println!("Initializing with: {}", init_value);
    }) as Box<dyn Fn()>)?;
    Ok(())
}

#[php_function]
pub fn create_lazy_proxy(obj: &mut ZendObject) -> PhpResult<()> {
    obj.make_lazy_proxy(Box::new(|| {
        // Return the real instance
        Some(ZendObject::new_stdclass())
    }) as Box<dyn Fn() -> Option<ZBox<ZendObject>>>)?;
    Ok(())
}
fn main() {}

Creating Lazy Objects from PHP

For full control over lazy object creation, use PHP’s Reflection API:

<?php
// Create a lazy ghost
$reflector = new ReflectionClass(MyClass::class);
$ghost = $reflector->newLazyGhost(function ($obj) {
    $obj->__construct('initialized');
});

// Create a lazy proxy
$proxy = $reflector->newLazyProxy(function ($obj) {
    return new MyClass('initialized');
});

Limitations

  • PHP 8.4+ only: Lazy objects are a PHP 8.4 feature and not available in earlier versions.

  • closure feature required: The make_lazy_ghost() and make_lazy_proxy() methods require the closure feature to be enabled.

  • User-defined classes only: PHP lazy objects only work with user-defined PHP classes, not internal classes. Since Rust-defined classes (using #[php_class]) are registered as internal classes, they cannot be made lazy.

  • Closure parameter access: Due to Rust trait system limitations, the make_lazy_ghost() and make_lazy_proxy() closures don’t receive the object being initialized as a parameter. Capture any needed initialization state in the closure itself.

Class Object

A class object is an instance of a Rust struct (which has been registered as a PHP class) that has been allocated alongside an object. You can think of a class object as a superset of an object, as a class object contains a Zend object.

T parameter&T parameterT Return type&T Return typePHP representation
No&ZendClassObject<T>Yes&mut ZendClassObject<T>Zend object and a Rust struct.

Examples

Returning a reference to self

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendClassObject};

#[php_class]
pub struct Example {
    foo: i32,
    bar: i32
}

#[php_impl]
impl Example {
    // ext-php-rs treats the method as associated due to the `self_` argument.
    // The argument _must_ be called `self_`.
    pub fn builder_pattern(
        self_: &mut ZendClassObject<Example>,
    ) -> &mut ZendClassObject<Example> {
        // do something with `self_`
        self_
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<Example>()
}
fn main() {}

Creating a new class instance

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_class]
pub struct Example {
    foo: i32,
    bar: i32
}

#[php_impl]
impl Example {
    pub fn make_new(foo: i32, bar: i32) -> Example {
        Example { foo, bar }
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<Example>()
}
fn main() {}

Closure

Rust closures can be passed to PHP through a wrapper class PhpClosure. The Rust closure must be static (i.e. can only reference things with a 'static lifetime, so not self in methods), and can take up to 8 parameters, all of which must implement FromZval. The return type must implement IntoZval.

Passing closures from Rust to PHP is feature-gated behind the closure feature. Enable it in your Cargo.toml:

ext-php-rs = { version = "...", features = ["closure"] }

PHP callables (which includes closures) can be passed to Rust through the Callable type. When calling a callable, you must provide it with a Vec of arguments, all of which must implement IntoZval and Clone.

T parameter&T parameterT Return type&T Return typePHP representation
CallableNoClosure, Callablefor PHP functionsNoCallables are implemented in PHP, closures are represented as an instance of PhpClosure.

Internally, when you enable the closure feature, a class PhpClosure is registered alongside your other classes:

<?php

class PhpClosure
{
    public function __invoke(..$args): mixed;
}

This class cannot be instantiated from PHP. When the class is invoked, the underlying Rust closure is called. There are three types of closures in Rust:

Fn and FnMut

These closures can be called multiple times. FnMut differs from Fn in the fact that it can modify variables in its scope.

Example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
pub fn closure_get_string() -> Closure {
    // Return a closure which takes two integers and returns a string
    Closure::wrap(Box::new(|a, b| {
        format!("A: {} B: {}", a, b)
    }) as Box<dyn Fn(i32, i32) -> String>)
}

#[php_function]
pub fn closure_count() -> Closure {
    let mut count = 0i32;

    // Return a closure which takes an integer, adds it to a persistent integer,
    // and returns the updated value.
    Closure::wrap(Box::new(move |a: i32| {
        count += a;
        count
    }) as Box<dyn FnMut(i32) -> i32>)
}
fn main() {}

FnOnce

Closures that implement FnOnce can only be called once. They consume some sort of value. Calling these closures more than once will cause them to throw an exception. They must be wrapped using the wrap_once function instead of wrap.

Internally, the FnOnce closure is wrapped again by an FnMut closure, which owns the FnOnce closure until it is called. If the FnMut closure is called again, the FnOnce closure would have already been consumed, and an exception will be thrown.

Example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
pub fn closure_return_string() -> Closure {
    let example: String = "Hello, world!".into();

    // This closure consumes `example` and therefore cannot be called more than once.
    Closure::wrap_once(Box::new(move || {
        example
    }) as Box<dyn FnOnce() -> String>)
}
fn main() {}

Closures must be boxed as PHP classes cannot support generics, therefore trait objects must be used. These must be boxed to have a compile time size.

Callable

Callables are simply represented as zvals. You can attempt to get a callable function by its name, or as a parameter. They can be called through the try_call method implemented on Callable, which returns a zval in a result.

Callable parameter

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
pub fn callable_parameter(call: ZendCallable) {
    let val = call.try_call(vec![&0, &1, &"Hello"]).expect("Failed to call function");
    dbg!(val);
}
fn main() {}

Named Arguments (PHP 8.0+)

You can call PHP functions with named arguments using try_call_named or try_call_with_named. Named arguments allow you to pass parameters by name rather than position, which is especially useful when dealing with functions that have many optional parameters.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::call_user_func_named;

#[php_function]
pub fn call_with_named_args() -> String {
    // Get str_replace function
    let str_replace = ZendCallable::try_from_name("str_replace")
        .expect("str_replace not found");

    // Call with named arguments in any order
    let result = str_replace.try_call_named(&[
        ("subject", &"Hello world"),
        ("search", &"world"),
        ("replace", &"PHP"),
    ]).expect("Failed to call str_replace");

    result.string().unwrap_or_default()
}

#[php_function]
pub fn call_with_mixed_args(callback: ZendCallable) {
    // Mix positional and named arguments
    let result = callback.try_call_with_named(
        &[&"positional_arg"],  // positional args first
        &[("named", &"named_value")],  // then named args
    ).expect("Failed to call function");
    dbg!(result);
}
fn main() {}

There’s also a convenient call_user_func_named! macro:

// Named arguments only
call_user_func_named!(callable, arg1: value1, arg2: value2)?;

// Positional arguments followed by named arguments
call_user_func_named!(callable, [pos1, pos2], named1: val1, named2: val2)?;

Functions & methods

PHP functions and methods are represented by the Function struct.

You can use the try_from_function and try_from_method methods to obtain a Function struct corresponding to the passed function or static method name. It’s heavily recommended you reuse returned Function objects, to avoid the overhead of looking up the function/method name.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

use ext_php_rs::zend::Function;

#[php_function]
pub fn test_function() -> () {
    let var_dump = Function::try_from_function("var_dump").unwrap();
    let _ = var_dump.try_call(vec![&"abc"]);
}

#[php_function]
pub fn test_method() -> () {
    let f = Function::try_from_method("ClassName", "staticMethod").unwrap();
    let _ = f.try_call(vec![&"abc"]);
}

fn main() {}

Zval Ownership: Separated & PhpRef

In PHP, there is a distinction between passing a value normally ($x) and passing it by reference (&$x). In ext-php-rs, a parameter type declares pass-by-reference through FromZvalMut::BY_REF. &mut Zval, PhpRef and &mut ZendHashTable set PHP’s ZEND_SEND_BY_REF flag. The flag forces callers to pass a variable, so a literal like foo([1, 2, 3]) is rejected at runtime.

Object parameters (&mut ZendObject, &mut MyClass) are passed by value. PHP objects are handles, so the caller sees every mutation without the flag.

Separated and PhpRef decouple Rust mutability from PHP’s pass-by-reference semantics.

When to use which

TypePHP syntaxModifies caller?Use case
Separatedfoo($x) or foo([1,2])NoMutate a local copy (COW)
PhpReffoo(&$x)YesModify the caller’s variable
&Zvalfoo($x)NoRead-only access
&mut Zvalfoo(&$x)YesLegacy, prefer PhpRef
&mut ZendHashTablefoo(&$x)YesMutate the caller’s array in place
&mut MyClassfoo($x) or foo(new MyClass)YesObjects are handles

Separated — local mutation without pass-by-reference

Separated wraps &mut Zval but does not set ZEND_SEND_BY_REF. PHP callers can pass any value, including literals. Call .array_mut() to trigger Copy-on-Write separation before mutating arrays.

use ext_php_rs::prelude::*;
use ext_php_rs::types::Separated;

#[php_function]
pub fn process_array(mut data: Separated) -> bool {
    let Some(ht) = data.array_mut() else {
        return false;
    };
    ht.push("appended").is_ok()
}
// Both work — no & required:
process_array([1, 2, 3]);

$arr = [1, 2, 3];
process_array($arr);
// $arr is unchanged — COW separation protects it

Since Separated implements Deref<Target = Zval>, all read methods (long(), str(), array(), object(), etc.) are available directly.

PhpRef — modify the caller’s variable

PhpRef is the explicit opt-in for PHP pass-by-reference. It sets ZEND_SEND_BY_REF, so the caller must pass a variable (not a literal). Mutations affect the original variable.

use ext_php_rs::prelude::*;
use ext_php_rs::types::PhpRef;

#[php_function]
pub fn increment(mut val: PhpRef) {
    if let Some(n) = val.long() {
        val.set_long(n + 1);
    }
}
$x = 5;
increment($x);
// $x is now 6

// increment(42); // Error: cannot pass by reference

How it works

Both types are #[repr(transparent)] newtypes over &mut Zval with zero runtime overhead. The difference is one constant on their FromZvalMut implementation: PhpRef sets BY_REF = true, Separated keeps the default false. The macro emits Arg::of::<T>(name), which reads that constant, so a type alias of PhpRef is also passed by reference.

The Zval::array_mut() method already implements PHP’s SEPARATE_ARRAY() semantics — it duplicates the underlying hashtable when the refcount is greater than 1. This is what makes Separated safe: the caller’s original value is never modified.

Macros

ext-php-rs comes with a set of macros that are used to annotate types which are to be exported to PHP. This allows you to write Rust-like APIs that can be used from PHP without fiddling around with zvals.

  • php_module - Defines the function used by PHP to retrieve your extension.
  • [php_startup] - Defines the extension startup function used by PHP to initialize your extension.
  • php_function - Used to export a Rust function to PHP.
  • php_class - Used to export a Rust struct or enum as a PHP class.
  • php_impl - Used to export a Rust impl block to PHP, including all methods and constants.
  • php_const - Used to export a Rust constant to PHP as a global constant.
  • php_extern - Attribute used to annotate extern blocks which are deemed as PHP functions.
  • php_interface - Attribute used to export Rust Trait as PHP interface
  • php - Used to modify the default behavior of the above macros. This is a generic attribute that can be used on most of the above macros.

#[php_module] Attribute

The module macro is used to annotate the get_module function, which is used by the PHP interpreter to retrieve information about your extension, including the name, version, functions and extra initialization functions. Regardless if you use this macro, your extension requires a extern "C" fn get_module() so that PHP can get this information.

The function is renamed to get_module if you have used another name. The macro also exports a crate-prefixed <crate_name>_get_module alias, used when statically linking the extension into php-src. Building with EXT_PHP_RS_STATIC_EXT=1 removes the unmangled get_module export (the function itself stays callable from Rust) so a statically linked extension cannot collide with another extension exporting get_module. Dynamic builds (extension= / dl()) must not set the variable.

The function is passed an instance of ModuleBuilder which allows you to register the following (if required):

  • Functions, classes, and constants
  • Extension and request startup and shutdown functions.
    • Read more about the PHP extension lifecycle here.
  • PHP extension information function
    • Used by the phpinfo() function to get information about your extension.

Classes and constants are not registered with PHP in the get_module function. These are registered inside the extension startup function.

Startup failures

If a constant, an interface, a class or an enum cannot be registered, the generated startup function logs the cause as an E_CORE_WARNING and returns FAILURE. PHP then reports Unable to start <extension> module. At engine startup PHP exits. From dl() the request fails. A panic during registration gets the same treatment. The process does not abort in either case.

get_module cannot report a failure, because PHP reads the returned entry without a check. If the ModuleBuilder cannot become a module entry, for example because a function name, an argument name or the module name contains a NUL byte, the macro returns a placeholder entry with the name of the crate. The startup function of that entry logs the build error and fails as described above.

The startup function that you name in #[php(startup = ...)] is your own extern "C" function. A panic inside it is not caught.

You can also give a startup function to ModuleBuilder::startup_function. The generated startup function calls it after the #[php(startup = ...)] function. The two functions run before the macro registers the constants, interfaces, classes and enums.

Usage

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{
    prelude::*,
    zend::ModuleEntry,
    info_table_start,
    info_table_row,
    info_table_end
};

#[php_const]
pub const MY_CUSTOM_CONST: &'static str = "Hello, world!";

#[php_class]
pub struct Test {
    a: i32,
    b: i32
}
#[php_function]
pub fn hello_world() -> &'static str {
    "Hello, world!"
}

/// Used by the `phpinfo()` function and when you run `php -i`.
/// This will probably be simplified with another macro eventually!
pub extern "C" fn php_module_info(_module: *mut ModuleEntry) {
    info_table_start!();
    info_table_row!("my extension", "enabled");
    info_table_end!();
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .constant(wrap_constant!(MY_CUSTOM_CONST))
        .class::<Test>()
        .function(wrap_function!(hello_world))
        .info_function(php_module_info)
}
fn main() {}

#[php_function] Attribute

Used to annotate functions which should be exported to PHP. Note that this should not be used on class methods - see the #[php_impl] macro for that.

See the list of types that are valid as parameter and return types. If the caller passes a value that the parameter type does not accept, the call throws a TypeError.

A function accepts the name, change_case, defaults and optional options. PHP functions have no visibility, so #[php(vis = "...")] is a compile error on a function.

Optional parameters

An Option<T> parameter accepts null and can be omitted. The macro reads this from the type through FromZvalMut::NULLABLE, so a type alias such as type MaybeAge = Option<i64> or the qualified path std::option::Option<i64> behaves like Option<i64>. The trailing run of parameters that are nullable or have a default value is optional.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
pub fn greet(name: String, age: Option<i32>) -> String {
    let mut greeting = format!("Hello, {}!", name);

    if let Some(age) = age {
        greeting += &format!(" You are {} years old.", age);
    }

    greeting
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(greet))
}
fn main() {}

Default parameter values can also be set for optional parameters. This is done through the #[php(defaults)] attribute option. When an optional parameter has a default, it does not need to be a variant of Option. The default expression is converted into the parameter type with Into, and the type must implement StubLiteral, which every scalar, string, Option, Vec and HashMap does. The module renders the value once at load time into the stub file and into ReflectionParameter::getDefaultValue(). Each key in defaults must name a parameter of the function. If a key names no parameter, the build fails:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_function]
#[php(defaults(offset = 0))]
pub fn rusty_strpos(haystack: &str, needle: &str, offset: i64) -> Option<usize> {
    let haystack: String = haystack.chars().skip(offset as usize).collect();
    haystack.find(needle)
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(rusty_strpos))
}
fn main() {}

Note that if there is a non-optional argument after an argument that is a variant of Option<T>, the Option<T> argument will be deemed a nullable argument rather than an optional argument.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

/// `age` will be deemed required and nullable rather than optional.
#[php_function]
pub fn greet(name: String, age: Option<i32>, description: String) -> String {
    let mut greeting = format!("Hello, {}!", name);

    if let Some(age) = age {
        greeting += &format!(" You are {} years old.", age);
    }

    greeting += &format!(" {}.", description);
    greeting
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(greet))
}
fn main() {}

You can also specify the optional arguments if you want to have nullable arguments before optional arguments. This is done through the optional attribute option, which names the first optional parameter. PHP callers can omit that parameter and every parameter after it. Each of these parameters must be an Option<T>, have a default, or be the variadic &[T] tail. Otherwise the build fails with an error on the type of that parameter. If optional names no parameter, the build also fails:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

/// `age` will be deemed required and nullable rather than optional,
/// while description will be optional.
#[php_function]
#[php(optional = "description")]
pub fn greet(name: String, age: Option<i32>, description: Option<String>) -> String {
    let mut greeting = format!("Hello, {}!", name);

    if let Some(age) = age {
        greeting += &format!(" You are {} years old.", age);
    }

    if let Some(description) = description {
        greeting += &format!(" {}.", description);
    }

    greeting
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(greet))
}
fn main() {}

Variadic Functions

A slice parameter &[T] in last position is variadic, the equivalent of the PHP ...$args syntax. T is any argument type: &[&Zval] receives the raw values, &[i64] receives converted integers. If one of the values cannot be converted into T, the call throws a TypeError. A variadic parameter that is not the last parameter is a compile error. The slice must be spelled in the signature; a type alias that hides the slice is not detected.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::Zval};

/// This can be called from PHP as `add(1, 2, 3, 4, 5)`
#[php_function]
pub fn add(number: u32, numbers:&[&Zval]) -> u32 {
    // numbers is a slice of 4 Zvals all of type long
    number
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(add))
}
fn main() {}

Performance

The #[php_function] macro generates a zero-allocation fast path that reads arguments directly from the PHP call frame, matching how native C extensions parse parameters. This applies automatically — no configuration needed.

The same fast path is used for class methods defined with #[php_impl], including instance methods (&self, &mut self) and static methods.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
// Fast path: standalone functions
#[php_function]
pub fn fast(name: String, age: Option<i32>) -> String { name }

// Fast path: parameters with defaults
#[php_function]
#[php(defaults(offset = 0))]
pub fn also_fast(haystack: &str, offset: i64) -> i64 { offset }
fn main() {}
#[php_impl]
impl MyClass {
    // Fast path: static methods
    pub fn create(n: i32) -> Self { /* ... */ }

    // Fast path: instance methods
    pub fn get_value(&self, offset: i32) -> i32 { /* ... */ }
}

The only case that falls back to the runtime argument parser is when using variadic arguments (&[T], the Rust equivalent of PHP’s ...$args):

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::Zval};
// Slower path: variadic arguments require runtime parsing
#[php_function]
pub fn add(first: u32, rest: &[&Zval]) -> u32 { first }
fn main() {}

Returning Result<T, E>

You can also return a Result from the function. The error variant will be translated into an exception and thrown. See the section on exceptions for more details.

#[php_interface] Attribute

You can export a Trait block to PHP. This exports all methods as well as constants to PHP on the interface. Trait method SHOULD NOT contain default implementations, as these are not supported in PHP interfaces.

Options

By default all constants are renamed to UPPER_CASE and all methods are renamed to camelCase. This can be changed by passing the change_method_case and change_constant_case as #[php] attributes on the impl block. The options are:

  • #[php(change_method_case = "snake_case")] - Renames the method to snake case.
  • #[php(change_constant_case = "snake_case")] - Renames the constant to snake case.

See the name and change_case section for a list of all available cases.

The macro itself takes no arguments: #[php_interface(name = "Foo")] is a compile error. Put the options in #[php(...)] on the trait.

Methods

A trait method accepts the name, change_case, defaults, optional and vis options. Interfaces cannot declare property accessors or constructors, so getter, setter and constructor are compile errors here. See php_impl for how each accepted option behaves.

Constants

A trait constant accepts the name and change_case options. Interface constants are always public, so vis is a compile error here. See php_impl for the value types.

Example

Define an example trait with methods and constant:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendClassObject};


#[php_interface]
#[php(name = "Rust\\TestInterface")]
trait Test {
    const TEST: &'static str = "TEST";

    fn co();

    #[php(defaults(value = 0))]
    fn set_value(&mut self, value: i32);
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .interface::<PhpInterfaceTest>()
}

fn main() {}

Using our newly created interface in PHP:

<?php

assert(interface_exists("Rust\TestInterface"));

class B implements Rust\TestInterface {

    public static function co() {}

    public function setValue(?int $value = 0) {

    }
}

Interface Inheritance

PHP interfaces can extend other interfaces. You can achieve this in two ways:

Using #[php(extends(...))]

Use the extends attribute to extend a built-in PHP interface or another Rust-defined interface. Generated stubs write each parent interface name as a fully qualified name, with a leading \. A leading \ in stub = "..." is optional.

For built-in PHP interfaces, use the explicit form:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ce;

#[php_interface]
#[php(extends(ce = ce::throwable, stub = "\\Throwable"))]
#[php(name = "MyException")]
trait MyExceptionInterface {
    fn get_error_code(&self) -> i32;
}

fn main() {}

For Rust-defined interfaces, you can use the simpler type syntax:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_interface]
trait BaseInterface {
    fn base_method(&self) -> i32;
}

#[php_interface]
#[php(extends(BaseInterface))]
trait ExtendedInterface {
    fn extended_method(&self) -> String;
}

fn main() {}

Using Rust Trait Bounds

You can also use Rust’s trait bound syntax. When a trait marked with #[php_interface] has supertraits, the PHP interface will automatically extend those parent interfaces:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_interface]
#[php(name = "Rust\\ParentInterface")]
trait ParentInterface {
    fn parent_method(&self) -> String;
}

// ChildInterface extends ParentInterface in PHP
#[php_interface]
#[php(name = "Rust\\ChildInterface")]
trait ChildInterface: ParentInterface {
    fn child_method(&self) -> String;
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .interface::<PhpInterfaceParentInterface>()
        .interface::<PhpInterfaceChildInterface>()
}

fn main() {}

In PHP:

<?php

// ChildInterface extends ParentInterface
assert(is_a('Rust\ChildInterface', 'Rust\ParentInterface', true));

#[php_impl_interface] Attribute

The #[php_impl_interface] attribute allows a Rust class to implement a custom PHP interface defined with #[php_interface]. This creates a relationship where PHP’s instanceof and is_a() recognize the implementation.

Key feature: The macro automatically registers the trait methods as PHP methods on the class. You don’t need to duplicate them in a separate #[php_impl] block.

Example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

// Define a custom interface
#[php_interface]
#[php(name = "Rust\\Greetable")]
trait Greetable {
    fn greet(&self) -> String;
}

// Define a class
#[php_class]
#[php(name = "Rust\\Greeter")]
pub struct Greeter {
    name: String,
}

#[php_impl]
impl Greeter {
    pub fn __construct(name: String) -> Self {
        Self { name }
    }

    // Note: No need to add greet() here - it's automatically
    // registered by #[php_impl_interface] below
}

// Implement the interface for the class
// This automatically registers greet() as a PHP method
#[php_impl_interface]
impl Greetable for Greeter {
    fn greet(&self) -> String {
        format!("Hello, {}!", self.name)
    }
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .interface::<PhpInterfaceGreetable>()
        .class::<Greeter>()
}

fn main() {}

Using in PHP:

<?php

$greeter = new Rust\Greeter("World");

// instanceof works
assert($greeter instanceof Rust\Greetable);

// is_a() works
assert(is_a($greeter, 'Rust\Greetable'));

// The greet() method is available (registered by #[php_impl_interface])
echo $greeter->greet(); // Output: Hello, World!

// Can be used as type hint
function greet(Rust\Greetable $obj): void {
    echo $obj->greet();
}

greet($greeter);

When to Use

  • Use #[php_impl_interface] for custom interfaces you define with #[php_interface]
  • Use #[php(implements(ce = ...))] on #[php_class] for built-in PHP interfaces like Iterator, ArrayAccess, Countable, etc.

See the Classes documentation for examples of implementing built-in interfaces.

Cross-Crate Support

The #[php_impl_interface] macro supports cross-crate interface discovery via the inventory crate. This means you can define an interface in one crate and implement it in another crate, and the implementation will be automatically discovered at link time.

Example: Defining an Interface in a Library Crate

First, create a library crate that defines the interface:

# my-interfaces/Cargo.toml
[package]
name = "my-interfaces"
version = "0.1.0"

[dependencies]
ext-php-rs = "0.15"
// my-interfaces/src/lib.rs
use ext_php_rs::prelude::*;

/// A serializable interface that can convert objects to JSON.
#[php_interface]
#[php(name = "MyInterfaces\\Serializable")]
pub trait Serializable {
    fn to_json(&self) -> String;
}

// Re-export the generated PHP interface struct for consumers
pub use PhpInterfaceSerializable;

Example: Implementing the Interface in Another Crate

Now create your extension crate that implements the interface:

# my-extension/Cargo.toml
[package]
name = "my-extension"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
ext-php-rs = "0.15"
my-interfaces = { path = "../my-interfaces" }
// my-extension/src/lib.rs
use ext_php_rs::prelude::*;
use my_interfaces::Serializable;

#[php_class]
#[php(name = "MyExtension\\User")]
pub struct User {
    name: String,
    email: String,
}

#[php_impl]
impl User {
    pub fn __construct(name: String, email: String) -> Self {
        Self { name, email }
    }

    // Note: No need to add to_json() here - it's automatically
    // registered by #[php_impl_interface] below
}

// Register the interface implementation
// This automatically registers to_json() as a PHP method
#[php_impl_interface]
impl Serializable for User {
    fn to_json(&self) -> String {
        format!(r#"{{"name":"{}","email":"{}"}}"#, self.name, self.email)
    }
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module
        // Register the interface from the library crate
        .interface::<my_interfaces::PhpInterfaceSerializable>()
        .class::<User>()
}

Using in PHP

<?php

use MyExtension\User;
use MyInterfaces\Serializable;

$user = new User("John", "john@example.com");

// instanceof works across crates
assert($user instanceof Serializable);

// Type hints work
function serialize_object(Serializable $obj): string {
    return $obj->toJson();
}

echo serialize_object($user);
// Output: {"name":"John","email":"john@example.com"}

Important Notes

  1. Automatic method registration: The #[php_impl_interface] macro automatically registers all trait methods as PHP methods on the class. You don’t need to duplicate them in a #[php_impl] block.

  2. Interface registration: The interface must be registered in the #[php_module] function using .interface::<PhpInterfaceName>().

  3. Link-time discovery: The inventory crate uses link-time registration for interface discovery, so all implementations are automatically discovered when the final binary is linked.

  4. Method names: Each method gets the PHP name that #[php_interface] declared for it. The trait’s change_method_case and the name or change_case of a trait method apply to the class too. #[php_impl_interface] takes no arguments.

#[php_class] Attribute

Structs can be exported to PHP as classes with the #[php_class] attribute macro. This attribute derives the RegisteredClass trait on your struct, as well as registering the class to be registered with the #[php_module] macro.

Options

There are additional macros that modify the class. These macros must be placed underneath the #[php_class] attribute.

  • name - Changes the name of the class when exported to PHP. The Rust struct name is kept the same. If no name is given, the name of the struct is used. Useful for namespacing classes.
  • change_case - Changes the case of the class name when exported to PHP.
  • readonly - Marks the class as readonly (PHP 8.2+). All properties in a readonly class are implicitly readonly.
  • flags - Sets class flags using ClassFlags, e.g. #[php(flags = ClassFlags::Final)] for a final class.
  • #[php(extends(...))] - Sets the parent class of the class. Can only be used once. Two forms are supported:
    • Simple type form: #[php(extends(MyBaseClass))] - For Rust-defined classes that implement RegisteredClass.
    • Explicit form: #[php(extends(ce = ce_fn, stub = "ParentClass"))] - For built-in PHP classes. ce_fn must be a function with the signature fn() -> &'static ClassEntry.
  • #[php(implements(...))] - Implements the given interface on the class. Can be used multiple times. Two forms are supported:
    • Simple type form: #[php(implements(MyInterface))] — For Rust-defined interfaces that implement RegisteredClass.
    • Explicit form: #[php(implements(ce = ce_fn, stub = "InterfaceName"))] — For built-in PHP interfaces. ce_fn must be a valid function with the signature fn() -> &'static ClassEntry.
  • Generated stubs write each parent class and interface name as a fully qualified name, with a leading \. A leading \ in stub = "..." is optional.

You may also use the #[php(prop)] attribute on a struct field to use the field as a PHP property. By default, the property is public and its name is the field name converted to camelCase: the field first_name gives the property firstName. Property types must implement IntoZval and FromZval.

You can customize properties with these options:

  • name - Allows you to rename the property, e.g. #[php(prop, name = "new_name")]
  • change_case - Allows you to rename the property using rename rules, e.g. #[php(prop, change_case = "PascalCase")]
  • static - Makes the property static (shared across all instances), e.g. #[php(prop, static)]
  • flags - Sets property visibility flags, e.g. #[php(prop, flags = ext_php_rs::flags::PropertyFlags::Private)]

How PHP code uses a Rust property

The value of a #[php(prop)] field is in the Rust struct. PHP reads the value with the getter and writes the value with the setter.

These operations use the getter and the setter:

  • $obj->prop += 1, $obj->prop .= 'x', $obj->prop++ and $obj->prop ??= 1.
  • == and foreach, which use the current Rust values. Thus == calls each getter of the two objects.

These operations change only a copy, and the Rust value does not change:

  • $obj->prop[] = 1, $obj->prop['key'] = 1 and $r = &$obj->prop. PHP gives the notice “Indirect modification of overloaded property”, as for __get.
  • foreach ($obj as &$value).

These operations throw an Error:

  • $obj->prop = &$value.
  • unset($obj->prop), because the Rust value cannot be removed.

If a PHP subclass declares the property again with hooks, PHP uses the Rust getter and setter, not the hooks.

Restrictions

No lifetime parameters

Rust lifetimes are used by the Rust compiler to reason about a program’s memory safety. They are a compile-time only concept; there is no way to access Rust lifetimes at runtime from a dynamic language like PHP.

As soon as Rust data is exposed to PHP, there is no guarantee which the Rust compiler can make on how long the data will live. PHP is a reference-counted language and those references can be held for an arbitrarily long time, which is untraceable by the Rust compiler. The only possible way to express this correctly is to require that any #[php_class] does not borrow data for any lifetime shorter than the 'static lifetime, i.e. the #[php_class] cannot have any lifetime parameters.

When you need to share ownership of data between PHP and Rust, instead of using borrowed references with lifetimes, consider using reference-counted smart pointers such as Arc.

No generic parameters

A Rust struct Foo<T> with a generic parameter T generates new compiled implementations each time it is used with a different concrete type for T. These new implementations are generated by the compiler at each usage site. This is incompatible with wrapping Foo in PHP, where there needs to be a single compiled implementation of Foo which is integrated with the PHP interpreter.

Example

This example creates a PHP class Human, adding a PHP property address.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_class]
pub struct Human {
    name: String,
    age: i32,
    #[php(prop)]
    address: String,
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<Human>()
}
fn main() {}

Create a custom exception RedisException, which extends Exception, and put it in the Redis\Exception namespace:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{
    prelude::*,
    exception::PhpException,
    zend::ce
};

#[php_class]
#[php(name = "Redis\\Exception\\RedisException")]
#[php(extends(ce = ce::exception, stub = "\\Exception"))]
#[derive(Default)]
pub struct RedisException;

// Throw our newly created exception
#[php_function]
pub fn throw_exception() -> PhpResult<i32> {
    Err(PhpException::from_class::<RedisException>("Not good!".into()))
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<RedisException>()
        .function(wrap_function!(throw_exception))
}
fn main() {}

Extending a Rust-defined Class

When extending another Rust-defined class, you can use the simpler type syntax:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_class]
#[derive(Default)]
pub struct Animal;

#[php_impl]
impl Animal {
    pub fn speak(&self) -> &'static str {
        "..."
    }
}

#[php_class]
#[php(extends(Animal))]
#[derive(Default)]
pub struct Dog;

#[php_impl]
impl Dog {
    pub fn speak(&self) -> &'static str {
        "Woof!"
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<Animal>()
        .class::<Dog>()
}
fn main() {}

Sharing Methods Between Parent and Child Classes

When both parent and child are Rust-defined classes, methods defined only in the parent won’t automatically work when called on a child instance. This is because each Rust type has its own object handlers.

The recommended workaround is to use a Rust trait for shared behavior:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

/// Trait for shared behavior
trait AnimalBehavior {
    fn speak(&self) -> &'static str {
        "..."
    }
}

#[php_class]
#[derive(Default)]
pub struct Animal;

impl AnimalBehavior for Animal {}

#[php_impl]
impl Animal {
    pub fn speak(&self) -> &'static str {
        AnimalBehavior::speak(self)
    }
}

#[php_class]
#[php(extends(Animal))]
#[derive(Default)]
pub struct Dog;

impl AnimalBehavior for Dog {
    fn speak(&self) -> &'static str {
        "Woof!"
    }
}

#[php_impl]
impl Dog {
    // Re-export the method so it works on Dog instances
    pub fn speak(&self) -> &'static str {
        AnimalBehavior::speak(self)
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<Animal>()
        .class::<Dog>()
}
fn main() {}

This pattern ensures that:

  • $animal->speak() returns "..."
  • $dog->speak() returns "Woof!"
  • $dog instanceof Animal is true

Implementing an Interface

To implement an interface, use #[php(implements(...))]. For built-in PHP interfaces, use the explicit form with ce and stub. For Rust-defined interfaces, you can use the simple type form. The following example implements ArrayAccess:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{
    prelude::*,
    exception::PhpResult,
    types::Zval,
    zend::ce,
};

#[php_class]
#[php(implements(ce = ce::arrayaccess, stub = "\\ArrayAccess"))]
#[derive(Default)]
pub struct EvenNumbersArray;

/// Returns `true` if the array offset is an even number.
/// Usage:
/// ```php
/// $arr = new EvenNumbersArray();
/// var_dump($arr[0]); // true
/// var_dump($arr[1]); // false
/// var_dump($arr[2]); // true
/// var_dump($arr[3]); // false
/// var_dump($arr[4]); // true
/// var_dump($arr[5] = true); // Fatal error:  Uncaught Exception: Setting values is not supported
/// ```
#[php_impl]
impl EvenNumbersArray {
    pub fn __construct() -> EvenNumbersArray {
        EvenNumbersArray {}
    }
    // We need to use `Zval` because ArrayAccess needs $offset to be a `mixed`
    pub fn offset_exists(&self, offset: &'_ Zval) -> bool {
        offset.is_long()
    }
    pub fn offset_get(&self, offset: &'_ Zval) -> PhpResult<bool> {
        let integer_offset = offset.long().ok_or("Expected integer offset")?;
        Ok(integer_offset % 2 == 0)
    }
    pub fn offset_set(&mut self, _offset: &'_ Zval, _value: &'_ Zval) -> PhpResult {
        Err("Setting values is not supported".into())
    }
    pub fn offset_unset(&mut self, _offset: &'_ Zval) -> PhpResult {
        Err("Setting values is not supported".into())
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<EvenNumbersArray>()
}
fn main() {}

Static Properties

Static properties are shared across all instances of a class. Use #[php(prop, static)] to declare a static property. Unlike instance properties, static properties are managed entirely by PHP and do not use Rust property handlers.

You can specify a default value using the default attribute:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::class::RegisteredClass;

#[php_class]
pub struct Counter {
    #[php(prop)]
    pub instance_value: i32,
    #[php(prop, static, default = 0)]
    pub count: i32,
    #[php(prop, static, flags = ext_php_rs::flags::PropertyFlags::Private)]
    pub internal_state: String,
}

#[php_impl]
impl Counter {
    pub fn __construct(value: i32) -> Self {
        Self {
            instance_value: value,
            count: 0,
            internal_state: String::new(),
        }
    }

    /// Increment the static counter from Rust
    pub fn increment() {
        let ce = Self::get_metadata().ce();
        let current: i64 = ce.get_static_property("count").unwrap_or(0);
        ce.set_static_property("count", current + 1).unwrap();
    }

    /// Get the current count
    pub fn get_count() -> i64 {
        let ce = Self::get_metadata().ce();
        ce.get_static_property("count").unwrap_or(0)
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<Counter>()
}
fn main() {}

From PHP, you can access static properties directly on the class:

// No need to initialize - count already has default value of 0
Counter::increment();
Counter::increment();
echo Counter::$count; // 2
echo Counter::getCount(); // 2

Abstract Classes

Abstract classes cannot be instantiated directly and may contain abstract methods that must be implemented by subclasses. Use #[php(flags = ClassFlags::Abstract)] to declare an abstract class:

use ext_php_rs::prelude::*;
use ext_php_rs::flags::ClassFlags;

#[php_class]
#[php(flags = ClassFlags::Abstract)]
pub struct AbstractAnimal;

#[php_impl]
impl AbstractAnimal {
    // Protected constructor for subclasses
    #[php(vis = "protected")]
    pub fn __construct() -> Self {
        Self
    }

    // Abstract method - must be implemented by subclasses.
    // Body is never called; use unimplemented!() as a placeholder.
    #[php(abstract)]
    pub fn speak(&self) -> String {
        unimplemented!()
    }

    // Concrete method - inherited by subclasses
    pub fn breathe(&self) {
        println!("Breathing...");
    }
}

From PHP, you can extend this abstract class:

class Dog extends AbstractAnimal {
    public function __construct() {
        parent::__construct();
    }

    public function speak(): string {
        return "Woof!";
    }
}

$dog = new Dog();
echo $dog->speak(); // "Woof!"
$dog->breathe();    // "Breathing..."

// This would cause an error:
// $animal = new AbstractAnimal(); // Cannot instantiate abstract class

See the impl documentation for more details on abstract methods.

Final Classes

Final classes cannot be extended. Use #[php(flags = ClassFlags::Final)] to declare a final class:

use ext_php_rs::prelude::*;
use ext_php_rs::flags::ClassFlags;

#[php_class]
#[php(flags = ClassFlags::Final)]
pub struct FinalClass;

Readonly Classes (PHP 8.2+)

PHP 8.2 introduced readonly classes, where all properties are implicitly readonly. You can create a readonly class using the #[php(readonly)] attribute:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_class]
#[php(readonly)]
pub struct ImmutablePoint {
    x: f64,
    y: f64,
}

#[php_impl]
impl ImmutablePoint {
    pub fn __construct(x: f64, y: f64) -> Self {
        Self { x, y }
    }

    pub fn get_x(&self) -> f64 {
        self.x
    }

    pub fn get_y(&self) -> f64 {
        self.y
    }

    pub fn distance_from_origin(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<ImmutablePoint>()
}
fn main() {}

From PHP:

$point = new ImmutablePoint(3.0, 4.0);
echo $point->getX(); // 3.0
echo $point->getY(); // 4.0
echo $point->distanceFromOrigin(); // 5.0

// On PHP 8.2+, you can verify the class is readonly:
$reflection = new ReflectionClass(ImmutablePoint::class);
var_dump($reflection->isReadOnly()); // true

The readonly attribute is compatible with other class attributes:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::flags::ClassFlags;

// Readonly + Final class
#[php_class]
#[php(readonly)]
#[php(flags = ClassFlags::Final)]
pub struct FinalImmutableData {
    value: String,
}
fn main() {}

Note: The readonly attribute requires PHP 8.2 or later. Using it when compiling against an earlier PHP version will result in a compile error.

Conditional Compilation for Multi-Version Support

If your extension needs to support both PHP 8.1 and PHP 8.2+, you can use conditional compilation to only enable readonly on supported versions.

First, add ext-php-rs-build as a build dependency in your Cargo.toml:

[build-dependencies]
ext-php-rs-build = "0.1"
anyhow = "1"

Then create a build.rs that detects the PHP version and emits cfg flags:

use ext_php_rs_build::{find_php, PHPInfo, ApiVersion, emit_php_cfg_flags, emit_check_cfg};

fn main() -> anyhow::Result<()> {
    let php = find_php()?;
    let info = PHPInfo::get(&php)?;
    let version: ApiVersion = info.zend_version()?.try_into()?;

    emit_check_cfg();
    emit_php_cfg_flags(version);
    Ok(())
}

Now you can use #[cfg(php82)] to conditionally apply the readonly attribute:

#[php_class]
#[cfg_attr(php82, php(readonly))]
pub struct MaybeReadonlyClass {
    value: String,
}

The ext-php-rs-build crate provides several useful utilities:

  • find_php() - Locates the PHP executable (respects the PHP env var)
  • PHPInfo::get() - Runs php -i and parses the output
  • ApiVersion - Enum representing PHP versions (Php80, Php81, Php82, etc.)
  • emit_php_cfg_flags() - Emits cargo:rustc-cfg=phpXX for all supported versions
  • emit_check_cfg() - Emits check-cfg to avoid unknown cfg warnings

This is optional - if your extension only targets PHP 8.2+, you can use #[php(readonly)] directly without any build script setup.

Cloning

PHP’s native clone operator is supported for #[php_class] structs that implement Clone. A derive or a hand-written impl Clone both work, and the spelling of the derive path does not matter. The cloned PHP object contains a copy of the Rust data:

use ext_php_rs::prelude::*;

#[php_class]
#[derive(Clone)]
pub struct Style {
    #[php(prop)]
    pub font_size: f64,
    #[php(prop)]
    pub color: String,
}

#[php_impl]
impl Style {
    pub fn __construct(font_size: f64, color: String) -> Self {
        Self { font_size, color }
    }
}
$style = new Style(12.0, 'red');
$copy = clone $style;

$copy->fontSize = 16.0;
echo $style->fontSize;  // 12.0 — original is unchanged

Structs that do not implement Clone throw an error when cloned:

// If MyClass does not implement Clone:
$obj = new MyClass();
$copy = clone $obj; // Error: Trying to clone an uncloneable object of class MyClass

Implementing Iterator

To make a Rust class usable with PHP’s foreach loop, implement the Iterator interface. This requires implementing five methods: current(), key(), next(), rewind(), and valid().

The following example creates a RangeIterator that iterates over a range of integers:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, zend::ce};

#[php_class]
#[php(implements(ce = ce::iterator, stub = "\\Iterator"))]
pub struct RangeIterator {
    start: i64,
    end: i64,
    current: i64,
    index: i64,
}

#[php_impl]
impl RangeIterator {
    /// Create a new range iterator from start to end (inclusive).
    pub fn __construct(start: i64, end: i64) -> Self {
        Self {
            start,
            end,
            current: start,
            index: 0,
        }
    }

    /// Return the current element.
    pub fn current(&self) -> i64 {
        self.current
    }

    /// Return the key of the current element.
    pub fn key(&self) -> i64 {
        self.index
    }

    /// Move forward to next element.
    pub fn next(&mut self) {
        self.current += 1;
        self.index += 1;
    }

    /// Rewind the Iterator to the first element.
    pub fn rewind(&mut self) {
        self.current = self.start;
        self.index = 0;
    }

    /// Checks if current position is valid.
    pub fn valid(&self) -> bool {
        self.current <= self.end
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<RangeIterator>()
}
fn main() {}

Using the iterator in PHP:

<?php

$range = new RangeIterator(1, 5);

// Use with foreach
foreach ($range as $key => $value) {
    echo "$key => $value\n";
}
// Output:
// 0 => 1
// 1 => 2
// 2 => 3
// 3 => 4
// 4 => 5

// Works with iterator functions
$arr = iterator_to_array(new RangeIterator(10, 12));
// [0 => 10, 1 => 11, 2 => 12]

$count = iterator_count(new RangeIterator(1, 100));
// 100

Iterator with Mixed Types

You can return different types for keys and values. The following example uses string keys:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, zend::ce};

#[php_class]
#[php(implements(ce = ce::iterator, stub = "\\Iterator"))]
pub struct MapIterator {
    keys: Vec<String>,
    values: Vec<String>,
    index: usize,
}

#[php_impl]
impl MapIterator {
    pub fn __construct() -> Self {
        Self {
            keys: vec!["first".into(), "second".into(), "third".into()],
            values: vec!["one".into(), "two".into(), "three".into()],
            index: 0,
        }
    }

    pub fn current(&self) -> Option<String> {
        self.values.get(self.index).cloned()
    }

    pub fn key(&self) -> Option<String> {
        self.keys.get(self.index).cloned()
    }

    pub fn next(&mut self) {
        self.index += 1;
    }

    pub fn rewind(&mut self) {
        self.index = 0;
    }

    pub fn valid(&self) -> bool {
        self.index < self.keys.len()
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<MapIterator>()
}
fn main() {}
<?php

$map = new MapIterator();
foreach ($map as $key => $value) {
    echo "$key => $value\n";
}
// Output:
// first => one
// second => two
// third => three

#[php_impl] Attribute

You can export an entire impl block to PHP. This exports all methods as well as constants to PHP on the class that it is implemented on. This requires the #[php_class] macro to already be used on the underlying struct. Trait implementations cannot be exported to PHP. Only one impl block can be exported per class.

If you do not want a function exported to PHP, you should place it in a separate impl block.

If you want to use async Rust, use #[php_async_impl], instead: see here » for more info.

Options

By default all constants are renamed to UPPER_CASE and all methods are renamed to camelCase. This can be changed by passing the change_method_case and change_constant_case as #[php] attributes on the impl block. The options are:

  • #[php(change_method_case = "snake_case")] - Renames the method to snake case.
  • #[php(change_constant_case = "snake_case")] - Renames the constant to snake case.

See the name and change_case section for a list of all available cases.

Methods

Methods basically follow the same rules as functions, so read about the [php_function] macro first. The primary difference between functions and methods is they are bounded by their class object.

Both instance and static methods benefit from the same zero-allocation fast path as standalone functions. See the Performance section for details.

Class methods can take a &self or &mut self parameter. They cannot take a consuming self parameter. Static methods can omit this self parameter.

To access the underlying Zend object, you can take a reference to a ZendClassObject<T> in place of the self parameter, where the parameter must be named self_. This can also be used to return a reference to $this.

The rest of the options are passed as separate attributes:

  • #[php(defaults(i = 5, b = "\"hello\""))] - Sets the default value for parameter(s). Each key must name a parameter.
  • #[php(optional = i)] - Sets the first optional parameter. Note that this also sets the remaining parameters as optional, so each of them must be an Option<T> or have a default. Otherwise the build fails.
  • #[php(vis = "public")], #[php(vis = "protected")] and #[php(vis = "private")] - Sets the visibility of the method.
  • #[php(name = "method_name")] - Renames the PHP method to a different identifier, without renaming the Rust method name.
  • #[php(final)] - Makes the method final (cannot be overridden in subclasses).
  • #[php(abstract)] - Makes the method abstract (must be implemented by subclasses). Can only be used in abstract classes.

The #[php(defaults)] and #[php(optional)] attributes operate the same as the equivalent function attribute parameters. A self_ parameter is the PHP object, not a PHP parameter, so defaults and optional cannot name it. A getter or setter method accepts neither option.

Static Methods

Methods that do not take a &self or &mut self parameter are automatically exported as static methods. These can be called on the class itself without creating an instance.

#[php_impl]
impl MyClass {
    // Static method - no self parameter
    pub fn create_default() -> Self {
        Self { /* ... */ }
    }

    // Instance method - takes &self
    pub fn get_value(&self) -> i32 {
        self.value
    }
}

From PHP:

$obj = MyClass::createDefault(); // Static call
$val = $obj->getValue();         // Instance call

Final Methods

Methods marked with #[php(final)] cannot be overridden in subclasses. This is useful when you want to prevent modification of critical functionality.

use ext_php_rs::prelude::*;

#[php_class]
pub struct SecureClass;

#[php_impl]
impl SecureClass {
    #[php(final)]
    pub fn secure_method(&self) -> &str {
        "This cannot be overridden"
    }

    // Final static methods are also supported
    #[php(final)]
    pub fn secure_static() -> i32 {
        42
    }
}

Abstract Methods

Methods marked with #[php(abstract)] must be implemented by subclasses. Abstract methods can only be defined in abstract classes (classes with ClassFlags::Abstract).

use ext_php_rs::prelude::*;
use ext_php_rs::flags::ClassFlags;

#[php_class]
#[php(flags = ClassFlags::Abstract)]
pub struct AbstractShape;

#[php_impl]
impl AbstractShape {
    // Protected constructor for subclasses
    #[php(vis = "protected")]
    pub fn __construct() -> Self {
        Self
    }

    // Abstract method - subclasses must implement this.
    // The body is never called; use unimplemented!() as a placeholder.
    #[php(abstract)]
    pub fn area(&self) -> f64 {
        unimplemented!()
    }

    // Concrete method in abstract class
    pub fn describe(&self) -> String {
        format!("A shape with area {}", self.area())
    }
}

Note: Abstract method bodies are never called - they exist only because Rust syntax requires a body for methods in impl blocks. Use unimplemented!() as a clear placeholder.

Note: If you try to use #[php(abstract)] on a method in a non-abstract class, you will get a compile-time error.

Note: PHP does not support abstract static methods. If you need static behavior that can be customized by subclasses, use a regular instance method or the late static binding pattern in PHP.

Constructors

By default, if a class does not have a constructor, it is not constructable from PHP. It can only be returned from a Rust function to PHP.

Constructors are Rust methods which can take any amount of parameters and returns either Self or Result<Self, E>, where E: Into<PhpException>. When the error variant of Result is encountered, it is thrown as an exception and the class is not constructed.

Constructors are designated by either naming the method __construct or by annotating a method with the #[php(constructor)] attribute. Note that when using the attribute, the function is not exported to PHP like a regular method.

Constructors cannot use the visibility or rename attributes listed above.

Constants

Constants are defined as regular Rust impl constants. Any type that implements IntoZval can be used as a constant. A constant accepts the name, change_case and vis options. The default visibility is public.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_class]
pub struct Limits;

#[php_impl]
impl Limits {
    const MAX_USERS: i64 = 100;

    #[php(vis = "protected")]
    const MAX_RETRIES: i64 = 3;

    #[php(vis = "private", name = "SEED")]
    const RANDOM_SEED: i64 = 42;
}
fn main() {}

PHP sees public const MAX_USERS, protected const MAX_RETRIES and private const SEED. Reflection reports the same visibility, and the generated stubs print it.

Property getters and setters

A class can expose a property whose value comes from Rust methods. Mark the method that reads the value with #[php(getter)] and the method that writes it with #[php(setter)]. The property name is the method name without its get_ or set_ prefix, converted to camelCase. This is the same rule as for #[php(prop)] fields: get_first_name gives the property firstName. Properties are declared in the order in which their first getter or setter appears in the impl block, after the #[php(prop)] field properties of the struct.

If you want a different property name, pass a name or change_case option to the #[php] attribute: #[php(getter, name = "first_name")] or #[php(getter, change_case = "snake_case")].

A property does not need both methods. A property with only a getter is read-only. A property with only a setter is write-only.

One method cannot be both a getter and a setter. A property cannot have two getters or two setters. The getter and the setter of one property must have the same visibility. Each of these cases is a compile error.

A method property cannot have the same name as a #[php(prop)] field of the struct. The two macros are expanded separately, so this is not a compile error. The extension fails to load with this error:

property `Book::$title` is declared twice

The PHP type of the property comes from the return type of the getter. If the property has no getter, the type comes from the argument of the setter. The documentation of the property comes from the getter, or from the setter when the getter has none. Like field properties, the type must implement IntoZval and FromZval.

Example

Continuing on from our Human example in the structs section, we will define a constructor, as well as getters for the properties. We will also define a constant for the maximum age of a Human.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{prelude::*, types::ZendClassObject};

#[php_class]
#[derive(Debug, Default)]
pub struct Human {
    name: String,
    age: i32,
    #[php(prop)]
    address: String,
}

#[php_impl]
impl Human {
    const MAX_AGE: i32 = 100;

    // No `#[constructor]` attribute required here - the name is `__construct`.
    pub fn __construct(name: String, age: i32) -> Self {
        Self {
            name,
            age,
            address: String::new()
        }
    }

    #[php(getter)]
    pub fn get_name(&self) -> String {
        self.name.to_string()
    }

    #[php(setter)]
    pub fn set_name(&mut self, name: String) {
        self.name = name;
    }

    #[php(getter)]
    pub fn get_age(&self) -> i32 {
        self.age
    }

    pub fn introduce(&self) {
        println!("My name is {} and I am {} years old. I live at {}.", self.name, self.age, self.address);
    }

    pub fn get_raw_obj(self_: &mut ZendClassObject<Human>) -> &mut ZendClassObject<Human> {
        dbg!(self_)
    }

    pub fn get_max_age() -> i32 {
        Self::MAX_AGE
    }
}
#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.class::<Human>()
}
fn main() {}

Using our newly created class in PHP:

<?php

$me = new Human('David', 20);

$me->introduce(); // My name is David and I am 20 years old.
var_dump(Human::get_max_age()); // int(100)
var_dump(Human::MAX_AGE); // int(100)

#[php_enum] Attribute

Enums can be exported to PHP as enums with the #[php_enum] attribute macro. This attribute derives the RegisteredClass and PhpEnum traits on your enum. To register the enum use the enumeration::<EnumName>() method on the ModuleBuilder in the #[php_module] macro.

Options

The #[php_enum] attribute can be configured with the following options:

  • #[php(name = "EnumName")] or #[php(change_case = "snake_case")]: Sets the name of the enum in PHP. The default is the PascalCase name of the enum.
  • #[php(allow_native_discriminants)]: Allows the use of native Rust discriminants (e.g., Hearts = 1).
  • #[php(change_cases_case = "snake_case")]: Sets the rename rule for every case that has no name or change_case of its own. The old name of this option, rename_cases, is a compile error.

PHP enums are always public. #[php(vis = "...")] on an enum is a compile error, and so is any argument passed to the macro itself, such as #[php_enum(name = "Suit")].

The cases of the enum can be configured with the following options:

  • #[php(name = "CaseName")] or #[php(change_case = "snake_case")]: Sets the name of the enum case in PHP. The default is the PascalCase name of the case.
  • #[php(value = "value")] or #[php(value = 123)]: Sets the discriminant value for the enum case. This can be a string or an integer. If not set, the case will be exported as a simple enum case without a discriminant.

Example

This example creates a PHP enum Suit.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_enum]
pub enum Suit {
    Hearts,
    Diamonds,
    Clubs,
    Spades,
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.enumeration::<Suit>()
}
fn main() {}

Backed Enums

Enums can also be backed by either i64 or &'static str. Those values can be set using the #[php(value = "value")] or #[php(value = 123)] attributes on the enum variants.

All variants must have a value of the same type, either all i64 or all &'static str.

A backed enum converts to and from its value. The macro implements From<Enum> for the value type, and TryFrom<i64> or TryFrom<&str> for the enum. TryFrom returns Error::InvalidEnumCase when no case has the value.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_enum]
pub enum Suit {
    #[php(value = "hearts")]
    Hearts,
    #[php(value = "diamonds")]
    Diamonds,
    #[php(value = "clubs")]
    Clubs,
    #[php(value = "spades")]
    Spades,
}
#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.enumeration::<Suit>()
}
fn main() {}

‘Native’ Discriminators

Native rust discriminants are currently not supported and will not be exported to PHP.

To avoid confusion a compiler error will be raised if you try to use a native discriminant. You can ignore this error by adding the #[php(allow_native_discriminants)] attribute to your enum.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_enum]
#[php(allow_native_discriminants)]
pub enum Suit {
    Hearts = 1,
    Diamonds = 2,
    Clubs = 3,
    Spades = 4,
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.enumeration::<Suit>()
}
fn main() {}

TODO: Add backed enums example

#[php_const] Attribute

Exports a Rust constant as a global PHP constant. The constant can be any type that implements IntoConst.

The wrap_constant!() macro can be used to simplify the registration of constants. It sets the name and doc comments for the constant.

You can rename the const with options:

  • name - Allows you to rename the property, e.g. #[php(name = "new_name")]
  • change_case - Allows you to rename the property using rename rules, e.g. #[php(change_case = "PascalCase")]

A global constant has no visibility. #[php(vis = "...")] on a #[php_const] is a compile error. Use vis on constants inside a #[php_impl] block.

Examples

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[php_const]
const TEST_CONSTANT: i32 = 100;

#[php_const]
#[php(name = "I_AM_RENAMED")]
const TEST_CONSTANT_THE_SECOND: i32 = 42;

#[php_const]
const ANOTHER_STRING_CONST: &'static str = "Hello world!";

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .constant(wrap_constant!(TEST_CONSTANT))
        .constant(wrap_constant!(TEST_CONSTANT_THE_SECOND))
        .constant(("MANUAL_CONSTANT", ANOTHER_STRING_CONST, &[]))
}
fn main() {}

PHP usage

<?php

var_dump(TEST_CONSTANT); // int(100)
var_dump(I_AM_RENAMED); // int(42)
var_dump(MANUAL_CONSTANT); // string(12) "Hello world!"

#[php_extern] Attribute

Attribute used to annotate extern blocks which are deemed as PHP functions.

This allows you to ‘import’ PHP functions into Rust so that they can be called like regular Rust functions. Parameters can be any type that implements IntoZval, and the return type can be anything that implements [From<Zval>] (notice how Zval is consumed rather than borrowed in this case).

Unlike most other attributes, this does not need to be placed inside a #[php_module] block.

Panics

The function can panic when called under a few circumstances:

  • The function could not be found or was not callable.
  • One of the parameters could not be converted into a Zval.
  • The actual function call failed internally.
  • The output Zval could not be parsed into the output type.

Inside a function that PHP calls, these panics become a PHP Error. The process does not abort. See Exceptions.

The last point can be important when interacting with functions that return unions, such as strpos which can return an integer or a boolean. In this case, a Zval should be returned as parsing a boolean to an integer is invalid, and vice versa.

Example

This extern block imports the strpos function from PHP. Notice that the string parameters can take either [String] or [&str], the optional parameter offset is an [Option<i64>], and the return value is a Zval as the return type is an integer-boolean union.

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{
    prelude::*,
    types::Zval,
};

#[php_extern]
extern "C" {
    fn strpos(haystack: &str, needle: &str, offset: Option<i64>) -> Zval;
}

#[php_function]
pub fn my_strpos() {
    assert_eq!(unsafe { strpos("Hello", "e", None) }.long(), Some(1));
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(my_strpos))
}
fn main() {}

ZvalConvert Derive Macro

The #[derive(ZvalConvert)] macro derives the FromZval and IntoZval traits on a struct or enum.

Structs

When used on a struct, the FromZendObject and IntoZendObject traits are also implemented, mapping fields to properties in both directions. All fields on the struct must implement FromZval as well. Generics are allowed on structs that use the derive macro, however, the implementation will add a FromZval bound to all generics types.

Examples

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[derive(ZvalConvert)]
pub struct ExampleClass<'a> {
    a: i32,
    b: String,
    c: &'a str
}

#[php_function]
pub fn take_object(obj: ExampleClass) {
    dbg!(obj.a, obj.b, obj.c);
}

#[php_function]
pub fn give_object() -> ExampleClass<'static> {
    ExampleClass {
        a: 5,
        b: "String".to_string(),
        c: "Borrowed",
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .function(wrap_function!(take_object))
        .function(wrap_function!(give_object))
}
fn main() {}

Calling from PHP:

<?php

$obj = new stdClass;
$obj->a = 5;
$obj->b = 'Hello, world!';
$obj->c = 'another string';

take_object($obj);
var_dump(give_object());

Another example involving generics:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

// T must implement both `PartialEq<i32>` and `FromZval`.
#[derive(Debug, ZvalConvert)]
pub struct CompareVals<T: PartialEq<i32>> {
    a: T,
    b: T
}

#[php_function]
pub fn take_object(obj: CompareVals<i32>) {
    dbg!(obj);
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .function(wrap_function!(take_object))
}
fn main() {}

Enums

When used on an enum, the FromZval implementation will treat the enum as a tagged union with a mixed datatype. This allows you to accept multiple types in a parameter, for example, a string and an integer.

The enum variants must not have named fields, and each variant must have exactly one field (the type to extract from the zval). Optionally, the enum may have one default variant with no data contained, which will be used when the rest of the variants could not be extracted from the zval.

The ordering of the variants in the enum is important, as the FromZval implementation will attempt to parse the zval data in order. For example, if you put a String variant before an integer variant, the integer would be converted to a string and passed as the string variant. Because an f64 or f32 variant also accepts an integer, the integer variants must come before it.

Examples

Basic example showing the importance of variant ordering and default field:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;

#[derive(Debug, ZvalConvert)]
pub enum UnionExample<'a> {
    Long(u64), // Long
    ProperStr(&'a str), // Actual string - not a converted value
    ParsedStr(String), // Potentially parsed string, i.e. a double
    None // Zval did not contain anything that could be parsed above
}

#[php_function]
pub fn test_union(val: UnionExample) {
    dbg!(val);
}

#[php_function]
pub fn give_union() -> UnionExample<'static> {
    UnionExample::Long(5)
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .function(wrap_function!(test_union))
        .function(wrap_function!(give_union))
}
fn main() {}

Use in PHP:

test_union(5); // UnionExample::Long(5)
test_union("Hello, world!"); // UnionExample::ProperStr("Hello, world!")
test_union(5.66666); // UnionExample::ParsedStr("5.6666")
test_union(null); // UnionExample::None
var_dump(give_union()); // int(5)

#[php] Attributes

There are a number of attributes that can be used to annotate elements in your extension.

Multiple #[php] attributes will be combined. For example, the following will be identical:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
#[php(name = "hi_world")]
#[php(defaults(a = 1, b = 2))]
fn hello_world(a: i32, b: i32) -> i32 {
    a + b
}
fn main() {}
#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
#[php_function]
#[php(name = "hi_world", defaults(a = 1, b = 2))]
fn hello_world(a: i32, b: i32) -> i32 {
    a + b
}
fn main() {}

Which attributes are available depends on the element you are annotating:

Attributeconstfnstructstruct fieldimplimpl constimpl fnenumenum case
name✅✅✅✅❌✅✅✅✅
change_case✅✅✅✅❌✅✅✅✅
change_method_case❌❌❌❌✅❌❌❌❌
change_constant_case❌❌❌❌✅❌❌❌❌
flags❌❌✅✅❌❌❌❌❌
prop❌❌❌✅❌❌❌❌❌
static❌❌❌✅❌❌❌❌❌
default❌❌❌✅ (static)❌❌❌❌❌
readonly❌❌✅❌❌❌❌❌❌
extends❌❌✅❌❌❌❌❌❌
implements❌❌✅❌❌❌❌❌❌
modifier❌❌✅❌❌❌❌❌❌
defaults❌✅❌❌❌❌✅❌❌
optional❌✅❌❌❌❌✅❌❌
vis❌❌❌❌❌✅✅❌❌
getter❌❌❌❌❌❌✅❌❌
setter❌❌❌❌❌❌✅❌❌
constructor❌❌❌❌❌❌✅❌❌
abstract❌❌❌❌❌❌✅❌❌
final❌❌❌❌❌❌✅❌❌
allow_native_discriminants❌❌❌❌❌❌❌✅❌
value❌❌❌❌❌❌❌❌✅
change_cases_case❌❌❌❌❌❌❌✅❌

An option marked ❌ is a compile error. The error points at the option and names the item where the option is valid. The proc macros themselves take no arguments: write #[php_class] and #[php(name = "Foo")], not #[php_class(name = "Foo")].

A method with getter or setter does not accept defaults or optional. Trait methods inside #[php_interface] accept name, change_case, defaults, optional and vis. Trait constants accept name and change_case. Methods inside #[php_impl_interface] and items under #[derive(ZvalConvert)] or #[php_extern] accept no #[php] option.

name and change_case

The name option sets the PHP name of an item to a string literal. The change_case option converts the Rust name to a different case. You can use only one of the two on an item. If you use both, the macro gives a compile error.

#[php(name = "NEW_NAME")]
#[php(change_case = "snake_case")]

The case is a string literal.

Available cases are:

  • snake_case
  • PascalCase
  • camelCase
  • UPPER_CASE
  • none - No change

Two items can get the same PHP name after renaming. For example, fn get_count and #[php(name = "GETCOUNT")] fn count_again both give the PHP method getCount, because PHP method names ignore case. If two methods, two constants or two enum cases of one block get the same PHP name, the macro gives a compile error at the second item. Method names are compared without case. Constant and enum case names are compared with case.

The macros cannot see a clash between two blocks, for example a method of #[php_impl] and a method of #[php_impl_interface] with the same PHP name. The builders check these at module startup. If two methods, two constants or two functions share a PHP name, or if a class with the same name is already registered, the extension does not start. The startup error names the item: Error::DuplicateMethod, Error::DuplicateConstant, Error::DuplicateFunction or Error::DuplicateClass.

Exceptions

Exceptions can be thrown from Rust to PHP. The inverse (catching a PHP exception in Rust) is currently being worked on.

Throwing exceptions

PhpException is the type that represents an exception. It contains the message contained in the exception, the type of exception and a status code to go along with the exception.

You can create a new exception with the new(), from_message(), or from_class::<T>() methods. Into<PhpException> is implemented for String and &str, which creates an exception of the type Exception with a code of 0. It may be useful to implement Into<PhpException> for your error type.

Calling the throw() method on a PhpException attempts to throw the exception in PHP. This function can fail if the type of exception is invalid (i.e. does not implement Exception or Throwable). Upon success, nothing will be returned.

throw() does nothing when an exception is already pending in the engine: that one keeps propagating with its own class and stack trace. This is what lets a PHP exception caught by ZendCallable::try_call reach the caller unchanged even though the Rust side reports it as Error::ExceptionPending.

To look at the pending exception from Rust, read its class with ExecutorGlobals::pending_exception_class(), or borrow the object through the globals guard, which must outlive the borrow:

let globals = ExecutorGlobals::get();
if let Some(exception) = globals.exception() {
    // inspect it; it stays owned by the engine
}

To handle it yourself, take ownership with ExecutorGlobals::take_exception(): the engine then stops propagating it and rethrowing becomes your responsibility.

IntoZval is also implemented for Result<T, E>, where T: IntoZval and E: Into<PhpException>. If the result contains the error variant, the exception is thrown. This allows you to return a result from a PHP function annotated with the #[php_function] attribute.

Examples

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use std::convert::TryInto;

// Trivial example - PHP represents all integers as `u64` on 64-bit systems
// so the `u32` would be converted back to `u64`, but that's okay for an example.
#[php_function]
pub fn something_fallible(n: u64) -> PhpResult<u32> {
    let n: u32 = n.try_into().map_err(|_| "Could not convert into u32")?;
    Ok(n)
}

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module
}
fn main() {}

Panics

A Rust panic never crosses the extern "C" boundary into PHP. The crate catches it at every entry point that it generates or installs:

  • Functions, methods, static methods, constructors and #[php_impl_interface] methods, through zend::run_handler.
  • Closure calls.
  • Property getters and setters, isset(), the (array) cast, var_export on a Rust-backed object, and clone.

When the panic reaches the boundary, the unwind already ran the destructors of every Rust frame inside the handler. The crate then throws a PHP Error exception and the handler returns normally. The message starts with Rust panic: and continues with the panic message. When the payload is not a string, for example after panic_any(42), the message continues with non-string panic payload. A catch (\Exception $e) block does not catch this Error. A catch (\Error $e) or catch (\Throwable $e) block does. The process does not stop, and the default panic hook still writes the location and the message to stderr.

If an exception is already pending when the panic happens, that exception continues to propagate. The panic then only reaches the panic hook. This is the rule that throw() also follows.

If Drop of a Rust-backed object panics, the crate reports an E_WARNING with the message Rust panic in Drop for <Class>: ... and frees the object. No exception is possible there, because the destructor also runs from the garbage collector and at request shutdown.

The following cases still stop the process:

  • A second panic while the first one unwinds, for example a Drop that panics. Rust turns this into an abort.
  • A build profile with panic = "abort". Nothing is caught.
  • The hooks that the crate installs outside of a PHP call: the observer, the zend extension, the error and exception observers, the module globals constructor and destructor, and the embed Sapi trampolines. The engine is in the middle of an operation there, so an exception or a bailout is not safe.
  • An extern "C" function that you give to the engine yourself: info_function, the request startup and shutdown functions, post_deactivate_function, the startup function of #[php_module], stream wrapper operations, and a handler that you write by hand for FunctionBuilder::new.

If you write a request-time handler by hand, run its body through zend::run_handler. The handler then behaves like generated code:

use ext_php_rs::zend::{ExecuteData, run_handler};
use ext_php_rs::types::Zval;
use std::panic::AssertUnwindSafe;

extern "C" fn my_handler(ex: &mut ExecuteData, retval: &mut Zval) {
    run_handler(AssertUnwindSafe(|| {
        // This code can panic, bail out or throw.
    }));
}

A Mutex in your own statics stays poisoned after a caught panic, as in any Rust program.

Output

ext-php-rs provides several macros and functions for writing output to PHP’s stdout and stderr streams. These are essential when your extension needs to produce output that integrates with PHP’s output buffering system.

Text Output

For regular text output (strings without NUL bytes), use the php_print! and php_println! macros. These work similarly to Rust’s print! and println! macros.

php_print!

Prints to PHP’s standard output without a trailing newline.

use ext_php_rs::prelude::*;

#[php_function]
pub fn greet(name: &str) {
    php_print!("Hello, {}!", name);
}

php_println!

Prints to PHP’s standard output with a trailing newline.

use ext_php_rs::prelude::*;

#[php_function]
pub fn greet(name: &str) {
    php_println!("Hello, {}!", name);
}

Note: php_print! and php_println! will panic if the string contains NUL bytes (\0). For binary-safe output, use php_output! or php_write!.

Binary-Safe Output

When working with binary data that may contain NUL bytes, use the binary-safe output functions. These are essential for outputting raw bytes, binary file contents, or any data that might contain \0 characters.

php_output!

Writes binary data to PHP’s output stream. This macro is both binary-safe AND respects PHP’s output buffering (ob_start()). This is usually what you want for binary output.

use ext_php_rs::prelude::*;

#[php_function]
pub fn output_binary() -> i64 {
    // Write binary data with NUL bytes - will be captured by ob_start()
    let bytes_written = php_output!(b"Hello\x00World");
    bytes_written as i64
}

php_write!

Writes binary data directly to the SAPI output, bypassing PHP’s output buffering. This macro is binary-safe but output will NOT be captured by ob_start(). The “ub” in ub_write stands for “unbuffered”.

use ext_php_rs::prelude::*;

#[php_function]
pub fn output_binary() -> i64 {
    // Write a byte literal
    php_write!(b"Hello World").expect("write failed");

    // Write binary data with NUL bytes (would panic with php_print!)
    let bytes_written = php_write!(b"Hello\x00World").expect("write failed");

    // Write a byte slice
    let data: &[u8] = &[0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello"
    php_write!(data).expect("write failed");

    bytes_written as i64
}

The macro returns a Result<usize> with the number of bytes written, which can be useful for verifying that all data was output successfully. The error case occurs when the SAPI’s ub_write function is not available.

Function API

In addition to macros, you can use the underlying functions directly:

FunctionBinary-SafeOutput BufferingDescription
zend::printf()NoYesPrintf-style output (used by php_print!)
zend::output_write()YesYesBinary-safe buffered output
zend::write()YesNoBinary-safe unbuffered output

Example using functions directly

use ext_php_rs::zend::output_write;

fn output_data(data: &[u8]) {
    let bytes_written = output_write(data);
    if bytes_written != data.len() {
        eprintln!("Warning: incomplete write");
    }
}

Comparison

MacroBinary-SafeOutput BufferingSupports Formatting
php_print!NoYesYes
php_println!NoYesYes
php_output!YesYesNo
php_write!YesNoNo

When to Use Each

  • php_print! / php_println!: Use for text output with format strings, similar to Rust’s print! and println!. Best for human-readable messages.

  • php_output!: Use for binary data that needs to work with PHP’s output buffering. This is the recommended choice for most binary output needs.

  • php_write!: Use when you need direct, unbuffered output that bypasses PHP’s output layer. Useful for low-level SAPI interaction or when output buffering must be avoided.

Output Handlers

An output handler changes the output before PHP sends it. It is the Rust equivalent of ob_start() with a callback. Use zend::start_output_handler to put a handler on the top of the output buffer stack.

use std::borrow::Cow;

use ext_php_rs::prelude::*;
use ext_php_rs::zend::{OutputHandlerFlags, start_output_handler};

#[php_function]
pub fn start_uppercase() -> PhpResult<()> {
    start_output_handler("uppercase", 0, OutputHandlerFlags::Std, |input, _op| {
        Cow::Owned(input.to_ascii_uppercase())
    })?;
    Ok(())
}

PHP calls the handler with the buffered bytes and an OutputOp. The handler returns the bytes that PHP sends:

Return valueResultCopy
Cow::Borrowed(input) or &input[..n]PHP sends the start of the input.No
Other Cow::Borrowed slicePHP sends the slice.One
Cow::Owned(vec)PHP sends the new bytes.One
An empty slicePHP sends nothing.No

The handler does not copy the input. A copy of the output goes to the request heap, because PHP keeps the output after the handler returns.

PHP calls the handler at these times:

  • When the buffer gets to chunk_size bytes. A chunk_size of 0 sets no limit.
  • When userland calls ob_flush(), ob_clean() or ob_end_*().
  • At the end of the request, with OutputOp::Final.

OutputHandlerFlags tells which ob_*() functions userland can use on the handler. OutputHandlerFlags::Std is the default of ob_start().

start_output_handler returns an error when no request is active, for example during MINIT.

If the handler panics, PHP sends the buffered bytes without a change. Then PHP disables the handler. PHP discards the output that the handler writes while it runs.

Response Headers

Use these functions to change the response headers and the status code. They do the same work as the PHP functions in the table.

FunctionPHP equivalent
zend::set_header(header)header($header)
zend::add_header(header)header($header, false)
zend::remove_header(name)header_remove($name)
zend::remove_all_headers()header_remove()
zend::set_response_code(code)http_response_code($code)
use ext_php_rs::prelude::*;
use ext_php_rs::zend::{set_header, set_response_code};

#[php_function]
pub fn moved(location: &str) -> PhpResult<()> {
    set_header(&format!("Location: {location}"))?;
    set_response_code(301)?;
    Ok(())
}

PHP applies its usual rules to the header. A Location header sets the status to 302, or to 303 for a request that is not GET or HEAD. To send a different code, call set_response_code after set_header.

Each function returns an error in these conditions:

  • No request is active, for example during MINIT.
  • A SapiGlobals guard is alive. The engine changes the SAPI globals, so you must drop the guard first.
  • The header is empty.
  • PHP refuses the header. PHP also emits a warning when it sent the headers before, or when the header has a new line or a NUL byte.

INI Settings

Your PHP Extension may want to provide it’s own PHP INI settings to configure behaviour. This can be done in the #[php_startup] annotated startup function.

Registering INI Settings

All PHP INI definitions must be registered with PHP to get / set their values via the php.ini file or ini_get() / ini_set().

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::{IniEntryDef, IniEntryDefs};
use ext_php_rs::flags::IniEntryPermission;

static INI_ENTRIES: IniEntryDefs<2> = IniEntryDefs::new([
    IniEntryDef::new(
        c"my_extension.display_emoji",
        c"yes",
        IniEntryPermission::All,
    ),
    IniEntryDef::end(),
]);

pub fn startup(ty: i32, mod_num: i32) -> i32 {
    IniEntryDef::register(INI_ENTRIES.as_slice(), mod_num);

    0
}

#[php_module]
#[php(startup = "startup")]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
}
fn main() {}

Getting INI Settings

The INI values are stored as part of the GlobalExecutor, and can be accessed via the ini_values() function. To retrieve the value for a registered INI setting

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::{
    prelude::*,
    zend::ExecutorGlobals,
};

pub fn startup(ty: i32, mod_num: i32) -> i32 {
    // Get all INI values
    let ini_values = ExecutorGlobals::get().ini_values(); // HashMap<String, Option<String>>
    let my_ini_value = ini_values.get("my_extension.display_emoji"); // Option<Option<String>>

    0
}

#[php_module]
#[php(startup = "startup")]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
}
fn main() {}

Memory

IniEntryDef::register takes a &'static [IniEntryDef] terminated by IniEntryDef::end(). The engine borrows the table for the life of the process: PHP 8.5 stores it as zend_ini_entry.def and the CLI SAPI dereferences def->value when printing php --ini. IniEntryDef::new is a const fn, so the table is a plain static — the same thing a C extension writes as zend_ini_entry_def[] in .rodata.

use ext_php_rs::{flags::IniEntryPermission, zend::{IniEntryDef, IniEntryDefs}};

static INI: IniEntryDefs<2> = IniEntryDefs::new([
    IniEntryDef::new(c"my_extension.display_emoji", c"1", IniEntryPermission::All),
    IniEntryDef::end(),
]);

pub fn startup(_ty: i32, mod_num: i32) -> i32 {
    IniEntryDef::register(INI.as_slice(), mod_num);
    0
}

Superglobals

PHP provides several superglobal arrays that are accessible from any scope. In ext-php-rs, you can access these superglobals using the ProcessGlobals, SapiGlobals, and ExecutorGlobals types.

Note for FrankenPHP users: In FrankenPHP worker mode, you would need to override sapi_activate/sapi_deactivate hooks to mimic php_rinit/php_rshutdown behavior. However, superglobals are inaccessible during sapi_activate and will cause crashes if accessed at that point.

Accessing HTTP Superglobals

The ProcessGlobals type provides access to the common HTTP superglobals:

MethodPHP Equivalent
http_get_vars()$_GET
http_post_vars()$_POST
http_cookie_vars()$_COOKIE
http_server_vars()$_SERVER
http_env_vars()$_ENV
http_files_vars()$_FILES
http_request_vars()$_REQUEST

Every accessor returns Option<&ZendHashTable>: None means the global has not been populated as an array (yet), for example before request startup or in a SAPI that does not provide it. The exception is http_request_vars(), which returns Result<Option<&ZendHashTable>> because building $_REQUEST involves the engine: Err reports an engine failure, Ok(None) means not populated.

Basic Example

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ProcessGlobals;

#[php_function]
pub fn get_cookie(name: String) -> Option<String> {
    ProcessGlobals::get()
        .http_cookie_vars()?
        .get(name.as_str())
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn get_query_param(name: String) -> Option<String> {
    ProcessGlobals::get()
        .http_get_vars()?
        .get(name.as_str())
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn get_post_param(name: String) -> Option<String> {
    ProcessGlobals::get()
        .http_post_vars()?
        .get(name.as_str())
        .and_then(|zval| zval.string())
}
fn main() {}

Accessing $_SERVER

The $_SERVER superglobal is lazy-initialized in PHP; http_server_vars() triggers that initialization before reading it:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ProcessGlobals;

#[php_function]
pub fn get_request_method() -> Option<String> {
    ProcessGlobals::get()
        .http_server_vars()?
        .get("REQUEST_METHOD")
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn get_remote_addr() -> Option<String> {
    ProcessGlobals::get()
        .http_server_vars()?
        .get("REMOTE_ADDR")
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn get_user_agent() -> Option<String> {
    ProcessGlobals::get()
        .http_server_vars()?
        .get("HTTP_USER_AGENT")
        .and_then(|zval| zval.string())
}
fn main() {}

Working with $_FILES

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ProcessGlobals;

#[php_function]
pub fn get_uploaded_file_name(field: String) -> Option<String> {
    let globals = ProcessGlobals::get();
    let files = globals.http_files_vars()?;

    // $_FILES structure: $_FILES['field']['name'], ['tmp_name'], ['size'], etc.
    files
        .get(field.as_str())?
        .array()?
        .get("name")
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn get_uploaded_file_tmp_path(field: String) -> Option<String> {
    let globals = ProcessGlobals::get();
    let files = globals.http_files_vars()?;

    files
        .get(field.as_str())?
        .array()?
        .get("tmp_name")
        .and_then(|zval| zval.string())
}
fn main() {}

Returning Superglobals to PHP

You can return copies of superglobals back to PHP:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::boxed::ZBox;
use ext_php_rs::types::ZendHashTable;
use ext_php_rs::zend::ProcessGlobals;

#[php_function]
pub fn get_all_cookies() -> Option<ZBox<ZendHashTable>> {
    Some(ProcessGlobals::get().http_cookie_vars()?.to_owned())
}

#[php_function]
pub fn get_all_get_params() -> Option<ZBox<ZendHashTable>> {
    Some(ProcessGlobals::get().http_get_vars()?.to_owned())
}

#[php_function]
pub fn get_server_vars() -> Option<ZBox<ZendHashTable>> {
    Some(ProcessGlobals::get().http_server_vars()?.to_owned())
}
fn main() {}

SAPI Request Information

For lower-level request information, use SapiGlobals:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::SapiGlobals;

#[php_function]
pub fn get_request_info() -> Vec<String> {
    let globals = SapiGlobals::get();
    let request_info = globals.request_info();

    let mut info = Vec::new();

    if let Some(method) = request_info.request_method() {
        info.push(format!("Method: {}", method));
    }
    if let Some(uri) = request_info.request_uri() {
        info.push(format!("URI: {}", uri));
    }
    if let Some(query) = request_info.query_string() {
        info.push(format!("Query: {}", query));
    }
    if let Some(content_type) = request_info.content_type() {
        info.push(format!("Content-Type: {}", content_type));
    }
    info.push(format!("Content-Length: {}", request_info.content_length()));

    info
}
fn main() {}

Available Request Info Methods

MethodDescription
request_method()HTTP method (GET, POST, etc.)
request_uri()Request URI
query_string()Query string
cookie_data()Raw cookie data
content_type()Content-Type header
content_length()Content-Length value
path_translated()Translated filesystem path
auth_user()HTTP Basic auth username
auth_password()HTTP Basic auth password

Accessing Constants

Use ExecutorGlobals to access PHP constants:

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ExecutorGlobals;

#[php_function]
pub fn get_php_version_constant() -> Option<String> {
    let globals = ExecutorGlobals::get();
    globals
        .constants()?
        .get("PHP_VERSION")
        .and_then(|zval| zval.string())
}

#[php_function]
pub fn constant_exists(name: String) -> bool {
    let globals = ExecutorGlobals::get();
    globals
        .constants()
        .is_some_and(|c| c.get(name.as_str()).is_some())
}
fn main() {}

Accessing INI Values

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::zend::ExecutorGlobals;

#[php_function]
pub fn get_memory_limit() -> Option<String> {
    ExecutorGlobals::get()
        .ini_values()
        .get("memory_limit")
        .cloned()
        .flatten()
}

#[php_function]
pub fn get_all_ini_values() -> Vec<(String, String)> {
    ExecutorGlobals::get()
        .ini_values()
        .iter()
        .filter_map(|(k, v)| {
            v.as_ref().map(|val| (k.clone(), val.clone()))
        })
        .collect()
}
fn main() {}

Thread Safety

All global access methods use guard types that provide thread-safe access:

  • ProcessGlobals::get() returns a GlobalReadGuard<ProcessGlobals>
  • ExecutorGlobals::get() returns a GlobalReadGuard<ExecutorGlobals>
  • SapiGlobals::get() returns a GlobalReadGuard<SapiGlobals>

The guard is automatically released when it goes out of scope. For mutable access (rarely needed), use get_mut():

#![cfg_attr(windows, feature(abi_vectorcall))]
extern crate ext_php_rs;
use ext_php_rs::zend::ExecutorGlobals;

fn example() {
    // Read-only access (most common)
    let globals = ExecutorGlobals::get();
    // ... use globals ...
    // Guard released when `globals` goes out of scope

    // Mutable access (rarely needed)
    let mut globals = ExecutorGlobals::get_mut();
    // ... modify globals ...
}
fn main() {}

PHP Example

<?php

// Assuming you've registered the functions above

// Access cookies
$session_id = get_cookie('session_id');

// Access query parameters
$page = get_query_param('page') ?? '1';

// Get request method
$method = get_request_method(); // "GET", "POST", etc.

// Get all cookies as array
$all_cookies = get_all_cookies();
print_r($all_cookies);

// Get request info
$info = get_request_info();
print_r($info);

// Access constants
$php_version = get_php_version_constant();
echo "PHP Version: $php_version\n";

// Check if constant exists
if (constant_exists('MY_CUSTOM_CONSTANT')) {
    echo "Constant exists!\n";
}

Summary

TypeUse Case
ProcessGlobalsHTTP superglobals ($_GET, $_POST, $_COOKIE, etc.)
SapiGlobalsLow-level request info, headers
ExecutorGlobalsConstants, INI values, function/class tables

All types are accessed via ::get() for read access or ::get_mut() for write access, and provide thread-safe access through guard types.

#[php_async_impl] Attribute

Using #[php_async_impl] instead of #[php_impl] allows us to expose any async Rust library to PHP, using PHP fibers, php-tokio and the PHP Revolt event loop under the hood to handle async interoperability.

This allows full compatibility with amphp, PSL, reactphp and any other async PHP library based on Revolt.

Traits annotated with #[php_async_impl] can freely expose any async function, using await and any async Rust library.

Make sure to also expose the php_tokio::EventLoop::init and php_tokio::EventLoop::wakeup functions to PHP in order to initialize the event loop, as specified in the full example here ».

Also, make sure to invoke EventLoop::shutdown in the request shutdown handler to clean up the tokio event loop before finishing the request.

Async example

In this example, we’re exposing an async Rust HTTP client library called reqwest to PHP, using PHP fibers, php-tokio and the PHP Revolt event loop under the hood to handle async interoperability.

This allows full compatibility with amphp, PSL, reactphp and any other async PHP library based on Revolt.

Make sure to require php-tokio as a dependency before proceeding.

extern crate ext_php_rs;
extern crate php_tokio;
extern crate reqwest;
use ext_php_rs::prelude::*;
use php_tokio::{php_async_impl, EventLoop};

#[php_class]
struct Client {}

#[php_async_impl]
impl Client {
    pub fn init() -> PhpResult<u64> {
        EventLoop::init()
    }
    pub fn wakeup() -> PhpResult<()> {
        EventLoop::wakeup()
    }
    pub async fn get(url: &str) -> anyhow::Result<String> {
        Ok(reqwest::get(url).await?.text().await?)
    }
}

pub extern "C" fn request_shutdown(_type: i32, _module_number: i32) -> i32 {
    EventLoop::shutdown();
    0
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.request_shutdown_function(request_shutdown)
}

Here’s the async PHP code we use to interact with the Rust class we just exposed.

The Client::init method needs to be called only once in order to initialize the Revolt event loop and link it to the Tokio event loop, as shown by the following code.

See here » for more info on async PHP using amphp + revolt.

<?php declare(strict_types=1);

namespace Reqwest;

use Revolt\EventLoop;

use function Amp\async;
use function Amp\Future\await;

final class Client
{
    private static ?string $id = null;

    public static function init(): void
    {
        if (self::$id !== null) {
            return;
        }

        $f = \fopen("php://fd/".\Client::init(), 'r+');
        \stream_set_blocking($f, false);
        self::$id = EventLoop::onReadable($f, fn () => \Client::wakeup());
    }

    public static function reference(): void
    {
        EventLoop::reference(self::$id);
    }
    public static function unreference(): void
    {
        EventLoop::unreference(self::$id);
    }

    public static function __callStatic(string $name, array $args): mixed
    {
        return \Client::$name(...$args);
    }
}


Client::init();

function test(int $delay): void
{
    $url = "https://httpbin.org/delay/$delay";
    $t = time();
    echo "Making async reqwest to $url that will return after $delay seconds...".PHP_EOL;
    Client::get($url);
    $t = time() - $t;
    echo "Got response from $url after ~".$t." seconds!".PHP_EOL;
};

$futures = [];
$futures []= async(test(...), 5);
$futures []= async(test(...), 5);
$futures []= async(test(...), 5);

await($futures);

Result:

Making async reqwest to https://httpbin.org/delay/5 that will return after 5 seconds...
Making async reqwest to https://httpbin.org/delay/5 that will return after 5 seconds...
Making async reqwest to https://httpbin.org/delay/5 that will return after 5 seconds...
Got response from https://httpbin.org/delay/5 after ~5 seconds!
Got response from https://httpbin.org/delay/5 after ~5 seconds!
Got response from https://httpbin.org/delay/5 after ~5 seconds!

Bailout Guard

A bailout is a jump that the engine makes with longjmp when it cannot continue. A fatal error, memory_limit exhaustion, or E_USER_ERROR cause a bailout. The jump skips the Rust frames between the error and the catch point. Rust does not run the destructors of the values in those frames. File handles, connections, and locks leak.

On Windows, the MSVC longjmp unwinds the Rust frames and their destructors run. Do not rely on this. Linux and macOS skip them.

exit() and die() do not cause a bailout on PHP 8. They throw an internal exception. The handler returns normally and Rust runs the destructors.

The problem

#[php_function]
pub fn process_file(callback: ZendCallable) {
    let file = File::open("data.txt").unwrap();

    // A fatal error in the callback leaks the file handle
    callback.try_call(vec![]);
}

try_call does not catch a bailout. The jump crosses process_file and stops at the try_catch that wraps every exported function. Rust never drops file.

Using BailoutGuard

use ext_php_rs::prelude::*;
use std::fs::File;

#[php_function]
pub fn process_file(callback: ZendCallable) {
    let file = BailoutGuard::new(File::open("data.txt").unwrap());

    // The file is closed even if a bailout occurs
    callback.try_call(vec![]);

    // Use the file via Deref
    // file.read_to_string(...);
}

How BailoutGuard works

A try_catch frame is a call to ext_php_rs::zend::try_catch. Every exported PHP function runs inside one. Embed::run and Embed::eval open one too.

  1. BailoutGuard::new moves the value to the heap. The value survives the longjmp.
  2. BailoutGuard::new registers a cleanup entry with the innermost try_catch frame.
  3. When you drop the guard, the guard releases the entry and drops the value.
  4. When a bailout occurs, the try_catch frame that catches it drops the guards that were created inside its closure, newest first. Then it returns Err(CatchError::Bailout). The guards that were created before that frame are not changed. You can still use them.

BailoutGuard is !Send. The guard belongs to the thread that created it.

Do not move a guard out of a try_catch closure through shared mutable state. If that closure bails out, the frame drops the value and the moved guard points at freed memory.

API

// Create a guard
let guard = BailoutGuard::new(value);

// Access the value (implements Deref and DerefMut)
guard.do_something();
let inner: &T = &*guard;
let inner_mut: &mut T = &mut *guard;

// Explicitly get references
let inner: &T = guard.get();
let inner_mut: &mut T = guard.get_mut();

// Extract the value and release the cleanup entry
let value: T = guard.into_inner();

Performance

BailoutGuard::new makes one heap allocation. Use it only for values that must be released:

  • File handles
  • Network connections
  • Database connections
  • Locks and mutexes
  • Other system resources

Do not wrap simple values. The allocation costs more than the leak.

Nested calls

The catching try_catch drops the guards of every Rust call level between the bailout and itself:

#[php_function]
pub fn outer_function(callback: ZendCallable) {
    let _outer_resource = BailoutGuard::new(Resource::new());

    inner_function(&callback);
}

fn inner_function(callback: &ZendCallable) {
    let _inner_resource = BailoutGuard::new(Resource::new());

    // On bailout, the try_catch of outer_function drops both resources
    callback.try_call(vec![]);
}

PHP code that outer_function calls can call a second exported Rust function. If that function bails out, its own try_catch drops only its own guards. Then it triggers the bailout again. The frame of outer_function drops _outer_resource.

Catching a bailout yourself

If you must continue after a failed engine call, open your own try_catch:

use ext_php_rs::zend::try_catch;

let connection = BailoutGuard::new(Connection::open());

let result = try_catch(|| {
    let _tmp = BailoutGuard::new(TempFile::create());
    risky_engine_call();
});

// On Err, try_catch dropped _tmp. connection is still valid here.
connection.query("...");

try_catch also catches a panic inside the closure and returns Err(CatchError::Panic(message)). It does not resume the panic. If you want the panic to continue, call std::panic::resume_unwind with your own payload.

Observer API

The Observer API allows you to build profilers, tracers, and instrumentation tools that observe PHP function calls and errors. This is useful for:

  • Performance profiling
  • Request tracing (APM)
  • Error monitoring
  • Code coverage tools
  • Debugging tools

Enabling the Feature

The Observer API is behind a feature flag. Add it to your Cargo.toml:

[dependencies]
ext-php-rs = { version = "0.15", features = ["observer"] }

Function Call Observer

Implement the FcallObserver trait to observe function calls:

use ext_php_rs::prelude::*;
use ext_php_rs::types::Zval;
use ext_php_rs::zend::ExecuteData;
use std::sync::atomic::{AtomicU64, Ordering};

struct CallCounter {
    count: AtomicU64,
}

impl CallCounter {
    fn new() -> Self {
        Self {
            count: AtomicU64::new(0),
        }
    }
}

impl FcallObserver for CallCounter {
    fn should_observe(&self, info: &FcallInfo) -> bool {
        !info.is_internal
    }

    fn begin(&self, _execute_data: &ExecuteData) {
        self.count.fetch_add(1, Ordering::Relaxed);
    }

    fn end(&self, _execute_data: &ExecuteData, _retval: Option<&Zval>) {}
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.fcall_observer(CallCounter::new)
}

The FcallObserver Trait

MethodDescription
should_observe(&self, info: &FcallInfo) -> boolCalled once per function definition. Result is cached by PHP.
begin(&self, execute_data: &ExecuteData)Called when function begins execution.
end(&self, execute_data: &ExecuteData, retval: Option<&Zval>)Called when function ends (even on exceptions).

FcallInfo - Function Metadata

FieldTypeDescription
function_nameOption<&str>Function name (None for anonymous/main)
class_nameOption<&str>Class name for methods
filenameOption<&str>Source file (None for internal functions)
linenou32Line number (0 for internal functions)
is_internalboolTrue for built-in PHP functions

Error Observer

Implement the ErrorObserver trait to observe PHP errors:

use ext_php_rs::prelude::*;
use std::sync::atomic::{AtomicU64, Ordering};

struct ErrorTracker {
    fatal_count: AtomicU64,
    warning_count: AtomicU64,
}

impl ErrorTracker {
    fn new() -> Self {
        Self {
            fatal_count: AtomicU64::new(0),
            warning_count: AtomicU64::new(0),
        }
    }
}

impl ErrorObserver for ErrorTracker {
    fn should_observe(&self, error_type: ErrorType) -> bool {
        (ErrorType::Fatal | ErrorType::Warning).contains(error_type)
    }

    fn on_error(&self, error: &ErrorInfo) {
        if ErrorType::Fatal.contains(error.error_type) {
            self.fatal_count.fetch_add(1, Ordering::Relaxed);

            if let Some(trace) = error.backtrace() {
                for frame in trace {
                    eprintln!("  at {}:{}",
                        frame.file.as_deref().unwrap_or("<internal>"),
                        frame.line
                    );
                }
            }
        } else {
            self.warning_count.fetch_add(1, Ordering::Relaxed);
        }
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.error_observer(ErrorTracker::new)
}

The ErrorObserver Trait

MethodDescription
should_observe(&self, error_type: ErrorType) -> boolFilter which error types to observe.
on_error(&self, error: &ErrorInfo)Called when an observed error occurs.

ErrorType - Error Level Bitflags

ErrorType::Error            // E_ERROR
ErrorType::Warning          // E_WARNING
ErrorType::Parse            // E_PARSE
ErrorType::Notice           // E_NOTICE
ErrorType::CoreError        // E_CORE_ERROR
ErrorType::CoreWarning      // E_CORE_WARNING
ErrorType::CompileError     // E_COMPILE_ERROR
ErrorType::CompileWarning   // E_COMPILE_WARNING
ErrorType::UserError        // E_USER_ERROR (deprecated since PHP 8.4)
ErrorType::UserWarning      // E_USER_WARNING
ErrorType::UserNotice       // E_USER_NOTICE
ErrorType::Strict           // E_STRICT (deprecated since PHP 8.4)
ErrorType::RecoverableError // E_RECOVERABLE_ERROR
ErrorType::Deprecated       // E_DEPRECATED
ErrorType::UserDeprecated   // E_USER_DEPRECATED

// Convenience groups, with the values of the PHP headers you build against
ErrorType::All   // E_ALL, contains Strict before PHP 8.4
ErrorType::Fatal // E_FATAL_ERRORS: Error | CoreError | CompileError | UserError | RecoverableError | Parse
ErrorType::Core  // E_CORE: CoreError | CoreWarning

ErrorInfo - Error Metadata

FieldTypeDescription
error_typeErrorTypeThe error level/severity
filenameOption<&str>Source file where error occurred
linenou32Line number
message&strThe error message

Lazy Backtrace

The backtrace() method captures the PHP call stack on demand:

fn on_error(&self, error: &ErrorInfo) {
    if let Some(trace) = error.backtrace() {
        for frame in trace {
            println!("{}::{}() at {}:{}",
                frame.class.as_deref().unwrap_or(""),
                frame.function.as_deref().unwrap_or("<main>"),
                frame.file.as_deref().unwrap_or("<internal>"),
                frame.line
            );
        }
    }
}

The backtrace is only captured when called, so there’s zero cost if unused.

Exception Observer

Implement the ExceptionObserver trait to observe thrown PHP exceptions:

use ext_php_rs::prelude::*;
use std::sync::atomic::{AtomicU64, Ordering};

struct ExceptionTracker {
    exception_count: AtomicU64,
}

impl ExceptionTracker {
    fn new() -> Self {
        Self {
            exception_count: AtomicU64::new(0),
        }
    }
}

impl ExceptionObserver for ExceptionTracker {
    fn on_exception(&self, exception: &ExceptionInfo) {
        self.exception_count.fetch_add(1, Ordering::Relaxed);
        eprintln!("[EXCEPTION] {}: {} at {}:{}",
            exception.class_name,
            exception.message.as_deref().unwrap_or("<no message>"),
            exception.file.as_deref().unwrap_or("<unknown>"),
            exception.line
        );
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.exception_observer(ExceptionTracker::new)
}

The ExceptionObserver Trait

MethodDescription
on_exception(&self, exception: &ExceptionInfo)Called when an exception is thrown, before any catch blocks.

ExceptionInfo - Exception Metadata

FieldTypeDescription
class_nameStringException class name (e.g., “RuntimeException”)
messageOption<String>The exception message
codei64The exception code
fileOption<String>Source file where thrown
lineu32Line number where thrown

Exception Backtrace

The backtrace() method captures the PHP call stack at exception throw time:

impl ExceptionObserver for MyObserver {
    fn on_exception(&self, exception: &ExceptionInfo) {
        eprintln!("[EXCEPTION] {}: {}",
            exception.class_name,
            exception.message.as_deref().unwrap_or("<no message>")
        );

        if let Some(trace) = exception.backtrace() {
            for frame in trace {
                eprintln!("  at {}::{}() in {}:{}",
                    frame.class.as_deref().unwrap_or(""),
                    frame.function.as_deref().unwrap_or("<main>"),
                    frame.file.as_deref().unwrap_or("<internal>"),
                    frame.line
                );
            }
        }
    }
}

The backtrace is lazy - only captured when called, so there’s zero cost if unused.

BacktraceFrame - Stack Frame Metadata

FieldTypeDescription
functionOption<String>Function name (None for main script)
classOption<String>Class name for method calls
fileOption<String>Source file
lineu32Line number

Zend Extension Handler

For low-level engine hooks beyond the Observer API – per-statement profiling, bytecode instrumentation, or op_array lifecycle tracking – register a ZendExtensionHandler. This registers your extension as a zend_extension alongside the regular PHP extension, the same mechanism used by OPcache, Xdebug, and dd-trace-php.

use ext_php_rs::prelude::*;
use ext_php_rs::ffi::zend_op_array;
use ext_php_rs::zend::ExecuteData;
use std::sync::atomic::{AtomicU64, Ordering};

struct StatementProfiler { count: AtomicU64 }

impl ZendExtensionHandler for StatementProfiler {
    fn on_statement(&self, _execute_data: &ExecuteData) {
        self.count.fetch_add(1, Ordering::Relaxed);
    }
    fn on_activate(&self) {
        self.count.store(0, Ordering::Relaxed);
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .zend_extension(|| StatementProfiler { count: AtomicU64::new(0) })
        .hook_statements()
        .finish()
}

Opt-in hooks

MethodEnablesCost when enabled
hook_op_array_compile()on_op_array_compiledOne callback per compiled function
hook_statements()on_statementExtra ZEND_EXT_STMT opcode on every statement of every compiled script
hook_fcalls()on_fcall_begin / on_fcall_endExtra ZEND_EXT_FCALL_BEGIN/END opcodes around every call site

Why opt in?

hook_statements() and hook_fcalls() tell the PHP engine to emit extra opcodes in every compiled script. Paying that tax by default would slow every PHP script, even when your profiler doesn’t need the data. The builder makes the trade-off explicit.

hook_op_array_compile() has no compile-time cost: PHP’s default CG(compiler_options) already includes ZEND_COMPILE_HANDLE_OP_ARRAY. Opting in only registers the dispatcher, so enabling it just adds one callback per compiled function.

The other hooks – on_activate, on_deactivate, on_message, on_op_array_ctor, on_op_array_dtor – are always wired when you register an extension; they don’t need opt-in because they’re cold-path.

ZTS note

Flags are re-asserted in on_activate so worker threads created after MINIT get them on their first request. Scripts pre-compiled by opcache before a thread’s first activation may miss hooks – for full coverage in ZTS with opcache, load the extension via zend_extension=... in php.ini.

Zend Extension vs Observer API

FeatureObserver API (FcallObserver)Zend Extension (ZendExtensionHandler)
Function call hooksbegin / end with return valueon_fcall_begin / on_fcall_end (legacy)
Filteringshould_observe (cached per function)No built-in filtering
Statement-level hooksNot availableon_statement
Bytecode accessNot availableon_op_array_compiled, on_op_array_ctor, on_op_array_dtor
Request lifecycleNot availableon_activate / on_deactivate
Best forFunction-level profiling, tracingStatement-level profiling, code coverage, bytecode instrumentation

Both can be registered on the same module simultaneously.

Using All Observers

You can register all observers on the same module:

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .fcall_observer(MyProfiler::new)
        .error_observer(MyErrorTracker::new)
        .exception_observer(MyExceptionTracker::new)
        .zend_extension(MyStatementProfiler::new)
        .hook_statements()
        .finish()
}

Thread Safety

Observers are created once, at the first MINIT, and stored as global singletons. When a SAPI stops the module and starts it again in the same process (for example a FrankenPHP worker restart), every MINIT registers the hooks again with the same instance, so its state is kept. They must implement Send + Sync because:

  • NTS: A single instance handles all requests
  • ZTS: The same instance may be called from different threads

Use thread-safe primitives like AtomicU64, Mutex, or RwLock for mutable state.

Best Practices

  1. Keep observers lightweight: Observer methods are called frequently. Avoid heavy computations or I/O.

  2. Use filtering wisely: should_observe results are cached for fcall observers. For error observers, filter early to avoid unnecessary processing.

  3. Handle errors gracefully: Don’t panic in observer methods.

  4. Consider memory usage: Implement limits or periodic flushing to avoid unbounded memory growth.

  5. Use lazy backtrace: Only call backtrace() when needed. Both ErrorInfo and ExceptionInfo support lazy backtrace capture.

Limitations

  • Only one fcall observer can be registered per extension
  • Only one error observer can be registered per extension
  • Only one exception observer can be registered per extension
  • Only one zend extension handler can be registered per extension
  • Observers and handlers are registered globally for the entire PHP process

Embedded PHP Execution

Extensions sometimes need to execute PHP code at runtime for setup tasks like registering autoloaders, defining helper classes, or configuring error handlers. The php_eval module lets you embed .php files into your extension binary at compile time and execute them from Rust.

Why Not eval?

This module uses zend_compile_string + zend_execute instead of zend_eval_string because:

  • zend_eval_string triggers security scanner false positives (eval-like semantics)
  • Some hardened PHP builds disable eval-like functionality
  • The embedded code is static bytes in the binary – there is no injection risk

Basic Usage

1. Write your PHP file

Create a normal .php file with full IDE support (syntax highlighting, linting, static analysis):

<?php
// php/setup.php

spl_autoload_register(function (string $class): void {
    $prefix = 'Acme\\Encryption\\';
    if (str_starts_with($class, $prefix)) {
        $relative = substr($class, strlen($prefix));
        $file = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
        if (file_exists($file)) {
            require $file;
        }
    }
});

function acme_encrypt_version(): string {
    return '1.0.0';
}

2. Embed and execute from Rust

Use include_bytes! or include_str! to embed the file at compile time, then call php_eval::execute() from whatever lifecycle hook fits your target SAPI. The function accepts any type that implements AsRef<[u8]>:

use ext_php_rs::prelude::*;
use ext_php_rs::php_eval;

// Both forms work:
const SETUP: &[u8] = include_bytes!("../php/setup.php");
// const SETUP: &str = include_str!("../php/setup.php");

unsafe extern "C" fn on_request_start(
    _type: i32,
    _module_number: i32,
) -> i32 {
    if let Err(e) = php_eval::execute(SETUP) {
        eprintln!("Failed to run embedded PHP setup: {:?}", e);
    }
    0
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.request_startup_function(on_request_start)
}

API Reference

php_eval::execute(code: impl AsRef<[u8]>) -> Result<(), PhpEvalError>

Compiles and executes the given PHP source within the running PHP engine.

Arguments:

  • code – Raw PHP source, typically from include_bytes! or include_str!. Any type implementing AsRef<[u8]> is accepted (&[u8], &str, String, Vec<u8>, etc.).

Returns:

  • Ok(()) on success.
  • Err(PhpEvalError::MissingOpenTag) if the code does not start with <?php (case-insensitive).
  • Err(PhpEvalError::CompilationFailed) if PHP cannot compile the code (syntax error).
  • Err(PhpEvalError::ExecutionFailed) if the code throws an unhandled exception.
  • Err(PhpEvalError::Bailout) if a PHP fatal error occurs.

Input handling

The code must start with a <?php opening tag (case-insensitive). The tag is stripped before compilation.

InputHandling
<?php opening tagRequired (case-insensitive), stripped before compilation
UTF-8 BOM (0xEF 0xBB 0xBF)Stripped before compilation
Empty after tag (e.g. <?php)Returns Ok(()) immediately
No <?php tag (including empty input)Returns Err(MissingOpenTag)

Lifecycle Hooks

The module does not prescribe when to run embedded PHP. The SAPI landscape is fragmented – FrankenPHP in worker mode does not trigger RINIT per request, for example. Choose the hook that fits your target:

HookUse case
RINIT (request_startup_function)Per-request setup (classic php-fpm / mod_php)
MINIT (startup_function)One-time global setup
Custom SAPI callbackWorker-mode runtimes (FrankenPHP, RoadRunner)

Error Handling

Errors during embedded PHP execution should not crash the host process. The recommended pattern is to log and continue:

if let Err(e) = php_eval::execute(SETUP_CODE) {
    match e {
        PhpEvalError::MissingOpenTag => {
            eprintln!("embedded PHP missing <?php open tag");
        }
        PhpEvalError::CompilationFailed => {
            eprintln!("embedded PHP syntax error");
        }
        PhpEvalError::ExecutionFailed => {
            eprintln!("embedded PHP threw an exception");
        }
        PhpEvalError::Bailout => {
            eprintln!("embedded PHP fatal error");
        }
    }
}

How It Works

  1. Build time: include_bytes! embeds the .php file contents into the extension binary as a &[u8] constant.

  2. Runtime: php_eval::execute() strips the <?php tag and BOM, then calls two C wrapper functions in wrapper.c:

    • ext_php_rs_zend_compile_string – compiles the source into an op_array. On PHP 8.2+ it passes ZEND_COMPILE_POSITION_AFTER_OPEN_TAG so the scanner starts directly in PHP mode. On PHP 8.1 the two-argument form is used.
    • ext_php_rs_zend_execute – executes the op_array, sets the execution scope, then cleans up static vars and frees the op_array.
  3. Safety: The entire execution is wrapped in try_catch to catch PHP bailouts (longjmp) without unwinding the Rust stack. Error reporting is suppressed during execution and restored afterward.

Custom SAPI

ext-php-rs allows you to implement a fully custom SAPI (Server API) in Rust using the Sapi trait. This lets you embed the PHP engine inside your own server, such as an HTTP framework, a message queue consumer, or any other application that needs to drive PHP request processing.

Overview

A SAPI implementation consists of three parts:

  1. ServerContext – per-request state (method, URI, POST body, headers).
  2. Sapi – the SAPI itself: output, logging, header sending.
  3. Trampolines – generated automatically by Sapi::build_module().

Defining a ServerContext

Implement the ServerContext trait for your per-request type:

use ext_php_rs::embed::{ServerContext, RequestInfo};

struct MyContext {
    method: String,
    uri: String,
    body: Vec<u8>,
    body_offset: usize,
    finished: bool,
}

impl ServerContext for MyContext {
    fn init_request_info(&self, info: &mut RequestInfo) {
        info.request_method = Some(self.method.clone());
        info.request_uri = Some(self.uri.clone());
        info.content_length = self.body.len() as i64;
    }

    fn read_post(&mut self, buf: &mut [u8]) -> usize {
        let remaining = &self.body[self.body_offset..];
        let n = buf.len().min(remaining.len());
        buf[..n].copy_from_slice(&remaining[..n]);
        self.body_offset += n;
        n
    }

    fn read_cookies(&self) -> Option<&str> {
        None
    }

    fn finish_request(&mut self) -> bool {
        if self.finished {
            return false;
        }
        self.finished = true;
        true
    }

    fn is_request_finished(&self) -> bool {
        self.finished
    }
}

Implementing the Sapi trait

use ext_php_rs::embed::{Sapi, ServerContext, RequestInfo, ServerVarRegistrar};

struct MyContext { method: String, uri: String, body: Vec<u8>, body_offset: usize, finished: bool }
impl ServerContext for MyContext {
    fn init_request_info(&self, _: &mut RequestInfo) {}
    fn read_post(&mut self, _: &mut [u8]) -> usize { 0 }
    fn read_cookies(&self) -> Option<&str> { None }
    fn finish_request(&mut self) -> bool { true }
    fn is_request_finished(&self) -> bool { true }
}
struct MySapi;

impl Sapi for MySapi {
    type Context = MyContext;

    fn name() -> &'static str { "my-sapi" }
    fn pretty_name() -> &'static str { "My Custom SAPI" }

    fn ub_write(_ctx: &mut MyContext, buf: &[u8]) -> usize {
        // Forward output to your HTTP response
        print!("{}", String::from_utf8_lossy(buf));
        buf.len()
    }

    fn log_message(msg: &str, _syslog_type: i32) {
        eprintln!("[php] {msg}");
    }
}

Building and starting the SAPI

use ext_php_rs::embed::Sapi;
struct MySapi;
impl Sapi for MySapi {
    type Context = ();
    fn name() -> &'static str { "x" }
    fn pretty_name() -> &'static str { "x" }
    fn ub_write(_ctx: &mut (), _buf: &[u8]) -> usize { 0 }
    fn log_message(_: &str, _: i32) {}
}
impl ext_php_rs::embed::ServerContext for () {
    fn init_request_info(&self, _: &mut ext_php_rs::embed::RequestInfo) {}
    fn read_post(&mut self, _: &mut [u8]) -> usize { 0 }
    fn read_cookies(&self) -> Option<&str> { None }
    fn finish_request(&mut self) -> bool { true }
    fn is_request_finished(&self) -> bool { true }
}

let module = MySapi::build_module().expect("failed to build SAPI module");

The returned SapiModule can then be passed to sapi_startup() and php_module_startup() just like a manually-built one. Both copy the struct by value (sapi_module = *sf), so a local variable is enough: keep it alive until sapi_shutdown() has returned and pass &raw mut module.

The builder places the SAPI’s name, pretty_name, executable_location and php_ini_path_override strings on the heap. PHP never frees them, so after sapi_shutdown() reclaim them with ext_php_rs::embed::cleanup_sapi_allocations (call it at most once, and only on a module built by SapiBuilder/Sapi::build_module). ini_entries is not touched: see the INI builder chapter for who owns it.

Registering $_SERVER variables

Override register_server_variables in the Sapi trait to populate $_SERVER:

use ext_php_rs::embed::{Sapi, ServerVarRegistrar};

struct MySapi;
struct Ctx;
impl ext_php_rs::embed::ServerContext for Ctx {
    fn init_request_info(&self, _: &mut ext_php_rs::embed::RequestInfo) {}
    fn read_post(&mut self, _: &mut [u8]) -> usize { 0 }
    fn read_cookies(&self) -> Option<&str> { None }
    fn finish_request(&mut self) -> bool { true }
    fn is_request_finished(&self) -> bool { true }
}
impl Sapi for MySapi {
    type Context = Ctx;
    fn name() -> &'static str { "x" }
    fn pretty_name() -> &'static str { "x" }
    fn ub_write(_: &mut Ctx, b: &[u8]) -> usize { b.len() }
    fn log_message(_: &str, _: i32) {}
    fn register_server_variables(
        _ctx: &mut Ctx,
        registrar: &mut ServerVarRegistrar,
    ) {
        registrar.register("SERVER_SOFTWARE", "my-server/1.0");
        registrar.register("SERVER_PROTOCOL", "HTTP/1.1");
    }
}

Static Linking into php-src

Extensions built with ext-php-rs are normally compiled as a cdylib and loaded at runtime with an extension= ini line. PHP also supports compiling extensions directly into the php binary. This is how fully static PHP builds work, for example FrankenPHP static binaries built with static-php-cli: there is no .so and no ini line, the extension is part of the binary.

This page describes how to build an ext-php-rs extension as a static library and link it into php-src. Note that this is an advanced use-case; if in doubt prefer loading your extension(s) dynamically and make sure to read the Caveats section below if you go the static route.

How php-src registers builtin extensions

Every builtin extension lives in php-src/ext/<name>/ with a config.m4. During ./buildconf, php-src scans the headers of enabled extensions for the token phpext_ and collects phpext_<name>_ptr pointers into the php_builtin_extensions[] array in main/internal_functions.c. That array is registered during php_module_startup().

The array needs a link-time-constant address, conventionally &<name>_module_entry. An ext-php-rs extension builds its zend_module_entry at runtime inside get_module(), so a small C shim bridges the two: it defines the <name>_module_entry symbol and fills it from the crate-prefixed <crate_name>_get_module() export in a constructor, which runs before php_module_startup().

Crate setup

Add staticlib to your crate type, keeping cdylib for regular builds:

[lib]
crate-type = ["staticlib", "cdylib"]

Static builds must be compiled with EXT_PHP_RS_STATIC_EXT=1:

EXT_PHP_RS_STATIC_EXT=1 cargo build --release

The variable does two things. It removes the unmangled get_module export (the crate-prefixed <crate_name>_get_module remains), so the binary can also contain C extensions that export get_module, for example ones missing the #ifdef COMPILE_DL_<EXT> guard around ZEND_GET_MODULE. It also defines ZEND_ENABLE_STATIC_TSRMLS_CACHE, which makes the extension read the main binary’s _tsrm_ls_cache thread-local directly instead of calling tsrm_get_ls_cache() on every globals access; php-src compiles all builtin extensions this way. The TSRMLS part only matters on ZTS targets and is harmless on NTS. Dynamic builds (extension= / dl()) must not set the variable, since PHP resolves the unprefixed get_module symbol.

Note that a static library built in this mode can only be linked into a PHP binary. It cannot be loaded with extension= or linked against a prebuilt libphp, because _tsrm_ls_cache only resolves inside php-src itself.

Generating the glue

The cargo-php CLI generates the three files php-src needs:

cargo php static-glue

This writes a directory named after your extension containing:

  • config.m4: enables the extension with --enable-<name> and links the prebuilt lib<name>.a.
  • php_<name>.h: declares <name>_module_entry and the phpext_<name>_ptr define that php-src scans for.
  • <name>_glue.c: the constructor shim copying *<crate_name>_get_module() into <name>_module_entry.

The extension name defaults to the library target name with dashes replaced by underscores; override it with --ext-name. The override only affects file and configure naming; the Rust symbols come from the library target name; override it with --lib-name if cargo metadata is not available.

The two-pass build

Building the Rust library requires php-config and the PHP headers, which do not exist yet when you are building the very PHP you want to link into. The solution is two passes:

  1. Build (or install) a PHP with the exact same version, ZTS mode and debug mode as your final binary. Only its php-config and headers are needed.

    cd php-src
    ./buildconf
    ./configure --enable-zts --disable-all --enable-cli --prefix=$HOME/php-pass1
    make -j$(nproc) && make install
    
  2. Build the Rust static library against pass 1:

    PHP_CONFIG=$HOME/php-pass1/bin/php-config \
    EXT_PHP_RS_STATIC_EXT=1 \
    cargo build --release
    
  3. Copy the glue and the static library into php-src:

    cargo php static-glue
    cp -r my_ext php-src/ext/my_ext
    cp target/release/libmy_ext.a php-src/ext/my_ext/
    
  4. Build the final PHP:

    cd php-src
    ./buildconf --force
    ./configure --enable-zts --enable-my_ext <other flags>
    make -j$(nproc)
    

    Alternatively, ./config.nice --enable-my_ext reruns configure with the flags of the previous invocation plus the new one.

  5. Verify:

    sapi/cli/php -m | grep my_ext
    sapi/cli/php -r 'var_dump(my_function());'
    

The module is listed without any extension= ini line. Note that php -m prints the module name from your crate’s Cargo.toml package name, which can differ from the C extension name when the package name contains dashes.

For FrankenPHP and static-php-cli the integration point is identical: drop the glue directory and the .a into the php-src tree those tools build from and add --enable-<name> to the configure flags.

Caveats

  • One Rust extension per PHP binary. The ext_php_rs_* symbols have fixed names, and two Rust static libraries collide on the Rust standard library symbols. Bundle all your Rust functionality into a single extension crate. Co-linking with C extensions is fine, including ones that export an unguarded get_module, as long as the Rust library is built with EXT_PHP_RS_STATIC_EXT=1.
  • gcc/clang only. The shim relies on __attribute__((constructor)). Statically linking into a Windows PHP build is not supported.
  • Keep the .a in sync. The static library embeds bindings generated from the pass-1 PHP headers. Rebuild it whenever the target PHP version changes.
  • --enable-<name>=shared is rejected by the generated config.m4: for shared builds, use the regular cdylib workflow instead.

Module Globals

PHP extensions can declare per-module global state that is automatically managed by the engine. In ZTS (thread-safe) builds, PHP’s TSRM allocates a separate copy of the globals for each thread. In non-ZTS builds, the globals are a plain static variable.

ext-php-rs exposes this via ModuleGlobals<T> and the ModuleGlobal trait.

Defining Globals

Create a struct that implements Default and ModuleGlobal:

use ext_php_rs::zend::{ModuleGlobal, ModuleGlobals};

#[derive(Default)]
struct MyGlobals {
    request_count: i64,
    max_depth: i32,
}

impl ModuleGlobal for MyGlobals {
    fn ginit(&mut self) {
        self.max_depth = 512;
    }
}

Default::default() initializes the struct, then ginit() runs for any additional setup. In ZTS mode these callbacks fire once per thread; in non-ZTS mode they fire once at module load.

If you don’t need custom initialization, leave the trait impl empty:

use ext_php_rs::zend::{ModuleGlobal, ModuleGlobals};
#[derive(Default)]
struct SimpleGlobals {
    counter: i64,
}

impl ModuleGlobal for SimpleGlobals {}

Registering Globals

Declare a static and pass it to ModuleBuilder::globals():

use ext_php_rs::prelude::*;
use ext_php_rs::zend::{ModuleGlobal, ModuleGlobals};

#[derive(Default)]
struct MyGlobals { request_count: i64, max_depth: i32 }
impl ModuleGlobal for MyGlobals {}

static MY_GLOBALS: ModuleGlobals<MyGlobals> = ModuleGlobals::new();

#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
    module.globals(&MY_GLOBALS)
}

Only one globals struct per module is supported (a PHP limitation).

Accessing Globals

Use get() for shared access and get_mut() for mutable access:

use ext_php_rs::prelude::*;
use ext_php_rs::zend::{ModuleGlobal, ModuleGlobals};

#[derive(Default)]
struct MyGlobals { request_count: i64, max_depth: i32 }
impl ModuleGlobal for MyGlobals {}
static MY_GLOBALS: ModuleGlobals<MyGlobals> = ModuleGlobals::new();

#[php_function]
pub fn get_request_count() -> i64 {
    MY_GLOBALS.get().request_count
}

#[php_function]
pub fn increment_request_count() {
    unsafe { MY_GLOBALS.get_mut() }.request_count += 1;
}

get() is safe because PHP runs one request per thread at a time. get_mut() is unsafe because the caller must ensure exclusive access (which is guaranteed within a single #[php_function] handler, but not from background Rust threads).

Advanced: Raw Pointer Access

For power users who need direct pointer access (e.g., passing to C APIs or building custom lock-free patterns), as_ptr() returns *mut T:

use ext_php_rs::zend::{ModuleGlobal, ModuleGlobals};
#[derive(Default)]
struct MyGlobals { request_count: i64 }
impl ModuleGlobal for MyGlobals {}
static MY_GLOBALS: ModuleGlobals<MyGlobals> = ModuleGlobals::new();
let ptr: *mut MyGlobals = MY_GLOBALS.as_ptr();

Cleanup

Implement gshutdown() if your globals hold external resources:

use ext_php_rs::zend::ModuleGlobal;
#[derive(Default)]
struct MyGlobals { handle: Option<u64> }
impl ModuleGlobal for MyGlobals {
    fn gshutdown(&mut self) {
        self.handle.take();
    }
}

The struct is also dropped after gshutdown() returns, so standard Drop implementations work as expected.

Worker Mode

Worker mode lets you recycle the PHP engine between requests without the overhead of a full php_request_shutdown() / php_request_startup() cycle. This is useful for long-running servers that serve many requests on the same thread.

How it works

A full PHP request cycle destroys and re-creates the executor state. Worker mode instead performs a lightweight shutdown that tears down only the SAPI and output layers, then re-activates them for the next request. This keeps compiled classes and functions in memory while resetting request-scoped state.

API

use ext_php_rs::embed::{
    worker_request_shutdown,
    worker_request_startup,
    worker_reset_superglobals,
};

// After processing a request:
worker_request_shutdown();

// Before the next request:
worker_request_startup().expect("startup failed");
worker_reset_superglobals();

Typical lifecycle

1. sapi_startup() + php_module_startup()    -- once per process
2. php_request_startup()                    -- first request
3. execute PHP script
4. worker_request_shutdown()                -- lightweight teardown
5. worker_request_startup()                 -- lightweight re-init
6. worker_reset_superglobals()              -- refresh $_SERVER etc.
7. execute PHP script                       -- next request
   ... repeat 4-7 ...
8. php_request_shutdown()                   -- final cleanup
9. php_module_shutdown() + sapi_shutdown()  -- once per process

ZTS and PhpThreadGuard

When PHP is compiled with ZTS (Zend Thread Safety), each OS thread needs its own thread-local storage. Use PhpThreadGuard to manage this automatically:

use ext_php_rs::embed::PhpThreadGuard;

std::thread::spawn(|| {
    let _guard = PhpThreadGuard::new();
    // This thread can now run PHP requests.
    // TLS is cleaned up when _guard is dropped.
});

The guard must be dropped before php_module_shutdown() is called.

Combining with the Sapi trait

Worker mode pairs naturally with a custom Sapi implementation. Build the SAPI module once, start it, then use worker mode to cycle between requests without tearing down the full engine.

Run code at the start of each request

In worker mode, PHP does not call the request startup function of an extension for each request. This is also true for a FrankenPHP worker. To run code for each request, use ModuleBuilder::sapi_activate_function.

use ext_php_rs::prelude::*;
use ext_php_rs::zend::{SapiRequestInfo, set_header, set_response_code};

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.sapi_activate_function(|info: &SapiRequestInfo| {
        if info.request_uri() == Some("/old") {
            let _ = set_header("Location: /new");
            let _ = set_response_code(301);
        }
    })
}

PHP calls the closure from sapi_module.activate at the start of each request. This includes each request of a FrankenPHP worker, and the first request that starts the worker script.

The closure gets the request data from the SAPI: the method, the URI, the query string and the cookies. The response headers are empty, so set_header and set_response_code work. $_SERVER does not exist yet, and PHP code cannot run.

Obey these rules:

  • Do not load the extension with dl(). PHP removes the extension at the end of the request, but the SAPI keeps the hook. The extension refuses to start.
  • Do not use a panic for control. PHP prints the panic message and continues the request.

A redirect in the closure does not stop the application. The script, or the worker callback, runs after the closure. It can replace the headers and write a body.

Memory Model at MINIT

Registering classes, functions, INI entries and constants builds C tables that the Zend engine reads. A C extension puts those tables in .rodata, so the linker owns them for the whole process and the question never comes up. ext-php-rs builds them on the heap at MINIT, so ownership has to be decided.

Each table falls into one of two buckets. Nothing sits in between, which is why ext-php-rs has no shutdown cleanup hook.

Reclaimed as soon as the engine has consumed them

Class and enum method tables. zend_register_functions interns every function name into a zend_string, and do_register_internal_class reads zend_class_entry.info.internal.builtin_functions exactly once and never frees it. The table is dead the moment registration returns, and ext-php-rs frees it there.

Owned for the life of the process

Everything the engine keeps a pointer into:

  • Argument info, including argument names, default values and class names in types. zend_register_functions copies the array only when the types it carries make the engine derive ZEND_ACC_HAS_RETURN_TYPE or ZEND_ACC_HAS_TYPE_HINTS, and that copy is shallow: name and default_value stay borrowed and are read at runtime by ReflectionParameter. A function with neither parameters nor a return type keeps the original array. When a SAPI starts the module again in the same process (a FrankenPHP worker restart), MINIT registers each class anew and its new tables replace those of the class freed with the old module.
  • The module function table, its function names, and the module name and version. module_destructor walks the table reading fname after MSHUTDOWN for dl()-loaded extensions, get_extension_funcs() walks it during a request, and a second php_module_startup in the same process is handed the same static entry.
  • The INI definition table and its strings. PHP 8.5 stores it as zend_ini_entry.def and the CLI SAPI reads def->value for php --ini=diff.

Each of these is owned by a per-extension static, never by a crate-wide one: the module’s tables live in StaticModuleEntry, a class’s argument info lives in that class’s ClassMetadata<T>, and an INI table is a static you pass to IniEntryDef::register. That is the same shape a C extension gets from its own data segment, and it is what makes a leak detector report them as still-reachable instead of lost. ext-php-rs holds no global mutable state for registration.

What this means for extension authors

Do not free anything you hand to a zend_register_* function, and do not free a ModuleEntry field. There is no cleanup entry point to call: #[php_module] leaves module_shutdown_func exactly as you set it.

Class constants and enum case discriminants are the one place to be careful when building tables by hand. zend_declare_class_constant and zend_enum_add_case take the zval payload with ZVAL_COPY_VALUE and no reference bump, so the engine owns any refcounted value afterwards and the Rust Zval must not run its destructor.

Module restarts

Some SAPIs stop the module and start it again in the same process. FrankenPHP does this on a worker restart, when its file watcher finds a change, and on opcache_reset(). At module shutdown, PHP frees all class entries. The next MINIT makes new class entries for the core classes and for your classes.

Thus a &'static ClassEntry from ext-php-rs is valid only until the module shuts down. This applies to ClassMetadata::ce(), ZendObject::get_class_entry() and the functions in zend::ce.

  • Do not keep a class entry in a static, a OnceLock or a thread-local value.
  • Get the class entry again each time that you use it. The call reads one pointer.

Observers stay registered after a restart. See Observer API.

Allowed Bindings

The extension limits the bindings that are generated by bindgen to a subset of the original bindings. Those bindings are defined in the allowed_bindings.rs file.

Should you need to add more bindings, you can do so by defining them as a comma-separated list in the EXT_PHP_RS_ALLOWED_BINDINGS environment variable.

This can be configured in your .cargo/config.toml file:

[env]
EXT_PHP_RS_ALLOWED_BINDINGS = "php_foo,php_bar"

Your bindings should now appear in the ext_php_rs::ffi module.

Pay attention to the PHP version

Be aware, that bindings that do not exist in the PHP version you are targeting will not be available.

Some bindings may also change between PHP versions, so make sure to test your extension with all PHP versions you are targeting.

Contributing

If you think that a binding should be added to the allowed bindings, please open an issue or a pull request on the GitHub repository so that everyone can benefit from it.

Migrating to v0.16

Zval::set_resource Requires an Unsafe Block

Zval::set_resource accepts a raw zend_resource pointer and transfers one owned Zend reference into the zval, so callers must now uphold its safety contract explicitly.

Before (v0.15)

zval.set_resource(resource);

After (v0.16)

// SAFETY: `resource` is a valid Zend-managed resource whose owned reference is
// transferred into `zval`.
unsafe { zval.set_resource(resource) };

The pointer must be non-null and valid until Zend releases the transferred reference. set_resource does not increment the resource refcount, so the caller must relinquish that reference after the call. Replacing a zval with the same resource requires a separate incoming reference.

SAPI Header Raw Constructors Removed

SapiHeader::from_raw and SapiHeaders::from_raw have been removed. These constructors accepted unchecked Zend pointers through safe Rust.

ext-php-rs now copies the header data into owned snapshots before invoking the Sapi::send_header and Sapi::send_headers callbacks.

Before (v0.15)

let header = SapiHeader::from_raw(raw_header);
let headers = SapiHeaders::from_raw(raw_headers);

After (v0.16)

Read header data from the snapshots supplied to your Sapi implementation:

fn send_header(_ctx: &mut MyContext, header: &SapiHeader) {
    if let Some((name, value)) = header.as_name_value() {
        // Send the header to the client.
    }
}

fn send_headers(
    _ctx: &mut MyContext,
    headers: &SapiHeaders,
) -> SendHeadersResult {
    let response_code = headers.http_response_code();
    SendHeadersResult::DoSend
}

Code that needs direct access to sapi_header_struct must use the raw FFI API and uphold Zend’s pointer validity and lifetime requirements itself.

Void Return Type for Functions Without Explicit Return

Functions and methods without an explicit Rust return type now declare void as their PHP return type.

Before (v0.15)

#[php_function]
pub fn do_something() {
    println!("Hello");
}

Generated PHP signature: function do_something() (implicit mixed return)

After (v0.16)

The same Rust code now generates: function do_something(): void

Migration

If your function actually returns a value but didn’t declare it, you must now add the return type:

// Before: worked but was incorrect
#[php_function]
pub fn get_value() {
    42  // implicitly returned, but no declared type
}

// After: must declare return type
#[php_function]
pub fn get_value() -> i32 {
    42
}

Exceptions

The magic methods __destruct and __clone are excluded from this change, as PHP forbids return type declarations on them.

Mutable Accessors Require &mut

This closes #461.

Several safe functions used to hand out a &mut derived from a shared &. That let ordinary safe code hold two mutable references to the same zval at once, which is undefined behaviour under Rust’s aliasing rules. The signatures below now require a mutable borrow so the compiler rejects that.

The affected functions are ZendHashTable::get_mut, ZendHashTable::get_index_mut, Zval::indirect_mut, ExecutorGlobals::function_table_mut, Zval::traversable, Zval::iterable, ClassEntry::get_iterator, and StreamWrapper::get / StreamWrapper::get_mut.

Mutable Hashtable Access

get_mut and get_index_mut take &mut self, so the binding must be mutable.

Before (v0.15)

fn bump(ht: &ZendHashTable) {
    if let Some(zv) = ht.get_mut("counter") {
        zv.set_long(1);
    }
}

After (v0.16)

fn bump(ht: &mut ZendHashTable) {
    if let Some(zv) = ht.get_mut("counter") {
        zv.set_long(1);
    }
}

The following no longer compiles, which is the point of the change:

let first = ht.get_mut("test").unwrap();
let second = ht.get_mut("test").unwrap();  // error[E0499]
first.set_long(2);

If you need to read two entries at once, take the second as a shared reference with get, or finish with the first before requesting the second.

Executor Globals

function_table_mut takes &mut self, so acquire the write guard rather than the read guard.

Before (v0.15)

let table = ExecutorGlobals::get().function_table_mut();

After (v0.16)

let mut globals = ExecutorGlobals::get_mut();
let table = globals.function_table_mut();

GlobalWriteGuard already implements DerefMut, so no other change is needed. Bind the guard to a variable, as the guard must outlive the borrow.

Iterables and Traversables

Zval::traversable and Zval::iterable take &mut self, and Iterable is now extracted through FromZvalMut instead of FromZval. Resolving the Traversable variant invokes the Zend get_iterator handler, which mutates the object, so a shared borrow was never sound.

Function signatures using Iterable as a parameter are unchanged and keep working, because the macro already extracts arguments through FromZvalMut:

#[php_function]
pub fn count_items(mut iterable: Iterable) -> usize {
    iterable.iter().map_or(0, Iterator::count)
}

Only hand-written extraction needs updating.

Before (v0.15)

let iterable = Iterable::from_zval(zval);
let iterator = zval.traversable();

After (v0.16)

let iterable = Iterable::from_zval_mut(zval);
let iterator = zval.traversable();

where zval is now a &mut Zval. ClassEntry::get_iterator likewise takes &'a mut Zval and ties the returned iterator’s lifetime to it.

Stream Wrappers

StreamWrapper::get and StreamWrapper::get_mut are now unsafe. They return a reference into PHP’s per-request stream wrapper table with a lifetime that is not constrained by their arguments, and get_mut cannot verify that no other reference to the same wrapper is live. This is the same class of change as Zval::set_resource above.

Before (v0.15)

let wrapper = StreamWrapper::get_mut("http").unwrap();

After (v0.16)

// SAFETY: used within the current request, and no other reference to the
// `http` wrapper is live for the duration of this borrow.
let wrapper = unsafe { StreamWrapper::get_mut("http") }.unwrap();

Both functions also copy the name into a NUL-terminated C string before calling into PHP. Previously they passed the bytes of a &str directly, and php_stream_locate_url_wrapper scans the path until it reaches a character outside [A-Za-z0-9+-.], so it could read past the end of the string slice and resolve the wrong wrapper.

Threading &mut Through a Handler

The requirement can propagate up several frames. A realistic case: a function that mutates entries of an array argument.

Before (v0.15)

#[php_function]
pub fn normalise(ht: &ZendHashTable) {
    rescale(ht);
}

fn rescale(ht: &ZendHashTable) {
    for key in ["width", "height"] {
        if let Some(zv) = ht.get_mut(key) {
            if let Some(value) = zv.long() {
                zv.set_long(value * 2);
            }
        }
    }
}

After (v0.16)

#[php_function]
pub fn normalise(ht: &mut ZendHashTable) {
    rescale(ht);
}

fn rescale(ht: &mut ZendHashTable) {
    for key in ["width", "height"] {
        if let Some(zv) = ht.get_mut(key) {
            if let Some(value) = zv.long() {
                zv.set_long(value * 2);
            }
        }
    }
}

&mut ZendHashTable is accepted as a #[php_function] parameter type, so the PHP-facing signature does not change. If you cannot obtain a mutable borrow at the outermost frame, the array is genuinely shared and mutating it was unsound before this release.

Engine-Input Accessors Return Option Instead of Panicking

Accessors that read data controlled by the engine or the request used to panic!, which aborts the process inside an extern "C" frame. They now return Option and align with http_server_vars, which already did.

Affected signatures:

Before (v0.15)After (v0.16)
ProcessGlobals::http_get_vars() -> &ZendHashTable-> Option<&ZendHashTable>
ProcessGlobals::http_post_vars() -> &ZendHashTable-> Option<&ZendHashTable>
ProcessGlobals::http_cookie_vars() -> &ZendHashTable-> Option<&ZendHashTable>
ProcessGlobals::http_env_vars() -> &ZendHashTable-> Option<&ZendHashTable>
ProcessGlobals::http_files_vars() -> &ZendHashTable-> Option<&ZendHashTable>
ProcessGlobals::http_request_vars() -> Option<&ZendHashTable>-> Result<Option<&ZendHashTable>>
SapiHeader::as_str() -> &str-> Option<&str>
SapiHeader::name() -> &str-> Option<&str>
zend::php_sapi_name() -> String-> Option<String>

http_request_vars distinguishes engine failures from absence: Err carries Error::AutoGlobalLoadFailed("_REQUEST") or Error::ZvalConversion where it previously panicked, and Ok(None) means the global is not populated. SapiHeader::value keeps its Option signature but returns None where it previously panicked (non-UTF-8 header).

Before (v0.15)

let get = ProcessGlobals::get().http_get_vars().to_owned();
let name = header.name();

After (v0.16)

let get = ProcessGlobals::get().http_get_vars()?.to_owned();
let name = header.name()?;

None means the global has not been populated as an array (yet) or, for headers, that the bytes are not valid UTF-8. In a regular request context the superglobals are populated and unwrapping stays reasonable; before request startup or in exotic SAPIs it no longer aborts the process.

describe Types Moved to ext-php-rs-introspection

The types behind ext_php_rs::describe now live in the ext-php-rs-introspection crate, which has no dependency on the Zend engine. cargo-php depends on that crate alone, so installing it no longer needs PHP. The paths under ext_php_rs::describe are unchanged re-exports. Three details of the ABI changed with the move.

DataType Zend Conversions Moved to DataTypeExt

DataType is defined in the new crate, so the conversions that need the Zend IS_* constants moved to the DataTypeExt trait, exported from the prelude.

Before (v0.15)

let raw = DataType::Long.as_u32();
let ty = DataType::from(raw);

After (v0.16)

use ext_php_rs::prelude::*;

let raw = DataType::Long.as_u32();
let ty = DataType::from_u32(raw);

describe::Class::flags Replaced by is_interface

Class carried the raw ce_flags word. It now carries the only bit stub generation needs.

Before (v0.15)

let is_interface = ClassFlags::from_bits(class.flags)
    .unwrap_or(ClassFlags::empty())
    .contains(ClassFlags::Interface);

After (v0.16)

let is_interface = class.is_interface;

Tuple From Impls for Method and Constant Removed

describe::Method and describe::Constant are now defined outside ext-php-rs, and Rust forbids implementing From<(A, B)> for a foreign type, so these conversions are gone: From<(FunctionBuilder, MethodFlags)> for Method, From<(String, Box<dyn IntoConst + Send>, DocComments)> for Constant and From<(String, DocComments)> for Constant. ext-php-rs uses private helpers internally. Downstream code that built these values by hand can fill the structs directly, every field is public.

Before (v0.15)

let constant: Constant = ("MY_CONST".to_string(), docs).into();

After (v0.16)

let constant = Constant {
    name: "MY_CONST".into(),
    docs: docs.into(),
    value: abi::Option::None,
};

Description::version Is the Introspection Version

Description::version was a &'static str holding the ext-php-rs version. It is now the first field, an abi::Str holding the ext-php-rs-introspection version. The entry point is renamed ext_php_rs_describe_module_v2 and returns *mut Description allocated with Box::into_raw instead of a Description by value; the new name keeps a v0.15 extension and a v0.16 cargo-php (or the reverse) from calling each other with the wrong calling convention. Only the #[php_module] macro emits that symbol, so this matters solely if you implement it by hand. cargo php stubs reads the version through the pointer before touching anything else, and cargo php stubs accepts an extension whose version is semver compatible with the one the CLI was built with. Module::enums is always present and Class::closure() is always available; the enum and closure features only decide whether ext-php-rs fills them.

DataType::Object Holds an abi::Str

DataType::Object carried an Option<&'static str>. That payload has a Rust layout inside a #[repr(C)] enum, so an extension and a cargo-php built with different Rust versions could read it differently. It now holds an abi::Str. An empty name means any object. DataType no longer implements PartialOrd and Ord.

Build the type with the two constructors and read it with class_name(). The #[php_class], #[php_interface] and #[derive(ZvalConvert)] macros already use them. Only hand-written IntoZval and FromZval implementations have to change.

Before (v0.15)

const TYPE: DataType = DataType::Object(Some(MyClass::CLASS_NAME));
const TYPE: DataType = DataType::Object(None);

if let DataType::Object(Some(name)) = ty {
    println!("{name}");
}

After (v0.16)

const TYPE: DataType = DataType::object(MyClass::CLASS_NAME);
const TYPE: DataType = DataType::ANY_OBJECT;

if let Some(name) = ty.class_name() {
    println!("{name}");
}

PackSlice::unpack_into Requires an Unsafe Block

PackSlice::unpack_into builds a &[T] over the bytes of a zend_string, so its length and alignment preconditions are now part of the signature. Zval::binary_slice checks both and stays safe. Pack::unpack_into stays safe as well: it now reads unaligned and never relied on alignment.

Before (v0.15)

let values: &[u64] = u64::unpack_into(zend_str);

After (v0.16)

// Preferred: the checked entry point.
let values: &[u64] = zval.binary_slice::<u64>()?;

// Or, when the checks are done by hand:
// SAFETY: `zend_str.len` is a multiple of 8 and `val` is 8-byte aligned.
let values: &[u64] = unsafe { u64::unpack_into(zend_str) };

Zval::binary_slice also returns None when the string bytes are not aligned for T. Zend allocates every string on an 8-byte boundary, so this only fires for types wider than 8 bytes or strings from a foreign allocator.

Zval::set_ptr Requires an Unsafe Block

Zval::set_ptr stores a raw pointer in an IS_PTR zval. The engine reads IS_PTR entries with a fixed type in several of its tables (property tables as zend_property_info *, constant tables as zend_class_constant *, INI directives as zend_ini_entry *), so storing an arbitrary pointer and letting the zval reach one of those tables is undefined behaviour.

Before (v0.15)

zval.set_ptr(ptr);

After (v0.16)

// SAFETY: `ptr` outlives the zval and the zval never reaches an engine table.
unsafe { zval.set_ptr(ptr) };

set_ptr now also releases the previous value of the zval instead of overwriting it in place.

Iterators Yield Owned Values

ZendIterator::get_current_data returned &'a Zval with an unconstrained 'a, so callers could keep the reference past the next move_forward. Zend releases that zval on every step (zend_user_it_invalidate_current runs zval_ptr_dtor on it), so the reference dangled. The reference is now tied to the &mut self borrow, and both ZendIterator’s and Iterable’s Iter yield (Zval, Zval): the value is a shallow clone, the same copy foreach makes in ZEND_FE_FETCH_R.

Before (v0.15)

let values: Vec<&Zval> = iterator.iter()?.map(|(_, v)| v).collect();

After (v0.16)

let values: Vec<Zval> = iterator.iter()?.map(|(_, v)| v).collect();

Destructuring for (k, v) in iterator.iter()? keeps working; v is a Zval instead of a &Zval, so drop the explicit & where you spelled the type.

ZBox::into_raw Returns *mut T

ZBox::into_raw fabricated a &'static mut T. It now mirrors Box::into_raw and returns *mut T, which ZBox::from_raw accepts back unchanged.

Before (v0.15)

let obj: &'static mut ZendObject = zbox.into_raw();
zval.set_object(obj);

After (v0.16)

let obj: *mut ZendObject = zbox.into_raw();
// SAFETY: `into_raw` yields a valid, exclusively owned object.
zval.set_object(unsafe { &mut *obj });

Heap-Leaking into_raw Constructors Removed

FunctionEntry::into_raw, IniEntryDef::into_raw, SapiModule::into_raw and the deprecated ModuleEntry::into_raw boxed a value and returned the pointer with no owner. None of them was needed: ModuleBuilder and ClassBuilder hand function tables to the engine themselves, IniEntryDef::register takes a Vec, and sapi_startup/php_module_startup copy the sapi_module_struct by value (sapi_module = *sf), so a local is enough.

Before (v0.15)

let sapi = builder.build()?.into_raw();
unsafe { sapi_startup(sapi) };
// ...
unsafe { cleanup_sapi_allocations(sapi); drop(Box::from_raw(sapi)) };

After (v0.16)

let mut sapi = builder.build()?;
unsafe { sapi_startup(&raw mut sapi) };
// ...
unsafe { cleanup_sapi_allocations(&raw mut sapi) };

Keep the SapiModule alive until sapi_shutdown() has returned: the engine copies the struct but keeps reading the strings it points to.

PhpRc Removed

The rc module and its PhpRc trait are removed. inc_count, dec_count and get_rc_mut changed the reference count from safe code. A wrong count lets the engine free an object that PHP still uses. ZendObject and ZendStr now have a read-only ref_count method.

Before (v0.15)

use ext_php_rs::rc::PhpRc;

let count = obj.get_count();

After (v0.16)

let count = obj.ref_count();

The crate has no public method to change the reference count. Use these wrappers instead:

  • To keep one more reference to a value, use Zval::shallow_clone.
  • To move an owned object into a zval, use the IntoZval impl of ZBox<ZendObject> or ZBox<ZendClassObject<T>>. Do not call dec_count and then Zval::set_object.

ZendStr::ref_count returns 1 for an interned string, like zend_string_refcount in PHP.

Observer ErrorType Merged Into flags::ErrorType

Two types had the name ErrorType. The observer type is removed. ErrorObserver, ErrorInfo and php_error now use flags::ErrorType. The prelude exports flags::ErrorType without the observer feature, and observer::ErrorType is a re-export of it.

The constants use CamelCase, like every other type in flags.

Before (v0.15)

fn should_observe(&self, error_type: ErrorType) -> bool {
    (ErrorType::FATAL | ErrorType::USER_WARNING).contains(error_type)
}

After (v0.16)

fn should_observe(&self, error_type: ErrorType) -> bool {
    (ErrorType::Fatal | ErrorType::UserWarning).contains(error_type)
}

Other changes:

  • ErrorType::All is E_ALL of the PHP version that you build against. Before PHP 8.4, it contains ErrorType::Strict. The old observer ALL never contained it.
  • ErrorType::Strict and ErrorType::UserError are deprecated on PHP 8.4 and later. The old observer USER_ERROR was not deprecated.
  • The type holds a u32, not an i32. bits() returns a u32, and from_bits and from_bits_truncate take a u32.

GlobalConstantFlags::CaseSensitive Removed

The flag was deprecated and had no effect since PHP 8.0, where constants are always case-sensitive. Remove it from any flag set you build.

alloc::emalloc Honours Layout::align

emalloc ignored the alignment of the Layout it was given. The Zend allocator guarantees ZEND_MM_ALIGNMENT (8 bytes) and has no aligned-allocation entry point, so emalloc now returns a null pointer for any Layout whose alignment exceeds 8 instead of handing back under-aligned memory.

zend::cleanup_module_allocations Removed

The helper freed the module’s function table, argument info and strings during MSHUTDOWN. That is unsound on every supported PHP version:

  • For a dl()-loaded extension, module_destructor calls zend_unregister_functions(module->functions, …) after the shutdown function returns, and that walks the table reading fname.
  • zend_register_functions copies the arg_info array only shallowly, so arg_info[i].name and .default_value stay borrowed by the engine and are read at runtime by ReflectionParameter. A function with neither parameters nor a return type keeps the array itself.

There is no replacement, and nothing to call. ext-php-rs now reclaims class and enum method tables as soon as the engine has interned them, and owns the tables the engine borrows for the life of the process. Extensions must not free anything they pass to zend_register_*.

Before (v0.15)

unsafe { ext_php_rs::zend::cleanup_module_allocations(entry) };

After (v0.16)

Delete the call. If you set module_shutdown_func through ModuleBuilder::shutdown_function, it is no longer wrapped and runs exactly as you wrote it.

Registration Tables Are Owned Per Extension

ext-php-rs no longer keeps a crate-wide list of the tables the Zend engine borrows for the life of the process. Each table is owned by a static belonging to the extension, the way a C extension owns its zend_module_entry, zend_function_entry[] and zend_internal_arg_info[].

Three signatures changed.

FunctionBuilder::build

Returns the argument info table alongside the entry, so the caller decides where it lives. Dropping it dangles every registered function.

// v0.15
let entry = builder.build()?;

// v0.16
let (entry, arg_info) = builder.build()?;

ClassBuilder and EnumBuilder do this for you: registration now receives the tables alongside the class entry, and ModuleBuilder::class::<T>() wires it to ClassMetadata<T>. A class built by hand must park them:

// v0.15
.registration(|ce| MY_META.set_ce(ce))

// v0.16
.registration(|ce, arg_info| {
    MY_META.set_ce(ce);
    MY_META.set_arg_info(arg_info);
})

TryFrom<ModuleBuilder>

Yields a third element, the allocations the ModuleEntry points into. The #[php_module] macro stores it in the extension’s StaticModuleEntry; only code that calls try_into() by hand is affected.

// v0.15
let (entry, startup): (ModuleEntry, ModuleStartup) = builder.try_into()?;

// v0.16
let (entry, startup, owned): (ModuleEntry, ModuleStartup, ModuleAllocations) =
    builder.try_into()?;

IniEntryDef::register

Takes a &'static [IniEntryDef] terminated by IniEntryDef::end() instead of a Vec, because PHP 8.5 keeps the table as zend_ini_entry.def and the CLI SAPI reads def->value at runtime. IniEntryDef::new is now a const fn taking &'static CStr, so the table is a plain static.

Before (v0.15)

let entries = vec![IniEntryDef::new(
    "my_ext.enabled".to_owned(),
    "1".to_owned(),
    &IniEntryPermission::All,
)];
IniEntryDef::register(entries, mod_num);

After (v0.16)

static INI: IniEntryDefs<2> = IniEntryDefs::new([
    IniEntryDef::new(c"my_ext.enabled", c"1", IniEntryPermission::All),
    IniEntryDef::end(),
]);

IniEntryDef::register(INI.as_slice(), mod_num);

IniEntryDefs<N> exists because IniEntryDef holds raw pointers and an array of them is not Sync on its own.

From<Arg> for ZendExpectedType removed

impl From<Arg<'_>> for _zend_expected_type is gone. It existed only to feed zend_wrong_parameter_type_error, which ext-php-rs never calls: argument mismatches go through ArgParser, which raises the error itself. Call zend_wrong_parameter_type_error through ext_php_rs::ffi if you relied on it.

The static feature is now _static

Linking libclang statically moved from static to _static. It still pairs with runtime the same way: the two are mutually exclusive, because clang-sys refuses to build them together.

# v0.15
ext-php-rs = { version = "0.15", default-features = false, features = ["enum", "static"] }

# v0.16
ext-php-rs = { version = "0.16", default-features = false, features = ["enum", "_static"] }

The underscore is what keeps cargo-semver-checks from selecting the feature on its own. It enables every feature it does not consider unstable, so with the old name it turned on both halves of the pair and could never build the crate, which in turn blocked the release tooling that runs it.

runtime is unchanged and stays the default.

The Error Model

Error is now Send + Sync

Error::Exception(ZBox<ZendObject>) is gone. It was the only variant holding engine memory, and it made the crate-wide Result unusable with threads or anyhow, which requires Send + Sync + 'static.

ZendCallable::try_call and friends keep their signatures. When the callable throws, the exception stays in the executor globals and the call returns Error::ExceptionPending { class }. Returning to PHP propagates the original exception with its class, message and stack trace; a Rust caller that wants to handle it takes ownership with ExecutorGlobals::take_exception().

// v0.15
match callable.try_call(vec![]) {
    Err(Error::Exception(obj)) => { /* the exception object */ }
    _ => {}
}

// v0.16
match callable.try_call(vec![]) {
    Err(Error::ExceptionPending { class }) => {
        let obj = ExecutorGlobals::take_exception();
    }
    _ => {}
}

PhpException::throw is now a no-op when an exception is already pending, so a Rust error can never bury the PHP exception that caused it.

Error messages are lowercase fragments

Every Display implementation follows the Rust convention: lowercase, no trailing period. Error::InvalidScope prints invalid scope, not Invalid scope.. Exception messages produced from an Error change to match.

Variants that carry context

v0.15v0.16
Error::InvalidPropertyError::InvalidProperty { property: String }
Error::InvalidCStringError::InvalidCString { position: usize }
Error::InvalidProperty (enum case lookup)Error::InvalidEnumCase { case: String }
Error::InvalidProperty (array key conversion)Error::ZvalConversion(DataType::String)
Error::InvalidProperty (ArrayKey::ZendString that is not UTF-8)Error::InvalidUtf8
Error::InvalidProperty (object passed to an enum conversion is not an enum)Error::ZvalConversion(DataType::ANY_OBJECT)
Error::UnknownDatatype(0) from TryFrom<ZvalTypeFlags>Error::InvalidTypeToDatatype(flags)

Error::UnknownDatatype is removed. It only ever appeared with a hardcoded 0 from the conversion above, which discarded the real flag bits.

Error::Catch(CatchError) is new: try_catch failures convert into it with ?, and source() reports whether the engine bailed out or the result pointer was null.

CatchError and EmbedError are printable

Both now implement Display and std::error::Error. CatchError is an enum with Bailout and NullPanicPtr instead of a unit struct that meant either. EmbedError::InitError, which was never constructed, is gone, and EmbedError::CatchError went from a unit variant to one carrying the CatchError that caused it:

// v0.15
Err(EmbedError::CatchError) => { /* was it a bailout? no way to tell */ }

// v0.16
Err(EmbedError::CatchError(CatchError::Bailout)) => { /* ... */ }

CatchError, EmbedError and PhpEvalError are now #[non_exhaustive], so a downstream match on any of them needs a wildcard arm. WorkerError already was.

PhpException::default is now from_message

The inherent default shadowed Default::default while taking an argument.

// v0.15
PhpException::default("something went wrong".into());

// v0.16
PhpException::from_message("something went wrong".into());

throw_object now validates that the zval holds an object and returns Err(Error::Object) otherwise, instead of letting the engine raise a fatal error.

Argument and return-type builders report a conversion error

Arg::as_arg_info and FunctionBuilder’s return-type path returned Error::InvalidCString when ZendType::empty_from_type could not represent a type, which had nothing to do with C strings. They now return Error::ZvalConversion(ty) naming the type that could not be represented.

ExecutorGlobals::throw_if_exception builds the exception directly

It used to go through Error::Exception and From<Error> for PhpException. It now takes the exception and attaches it to the PhpException itself, so the class and stack trace survive without a round trip through Error. The signature, PhpResult<()>, is unchanged.

Function::function_type returns a Result

From<u8> for FunctionType panicked on a value read straight out of zend_function. It is now TryFrom<u8>, and function_type() returns Result<FunctionType> with Error::UnknownFunctionType.

ZendHashTable::remove returns the removed value

remove and remove_index returned Option<()>, a boolean in costume. They now return Option<Zval>, matching OccupiedEntry::remove.

// v0.15
ht.remove("key");

// v0.16
let removed: Option<Zval> = ht.remove("key");

StreamWrapper::register no longer panics on a NUL byte

register and unregister returned a Result but panicked when the wrapper name contained a NUL byte. They now return Error::InvalidCString.

BailoutGuard Cleanup Belongs to the try_catch Frame

A try_catch frame now drops the BailoutGuards that were created inside its closure when it catches a bailout. Guards created before the frame are not changed. The old thread-wide cleanup ran later, in the generated handler, and could free a guard that an outer frame still used.

Before (v0.15)

let file = BailoutGuard::new(File::open("data.txt")?);
let result = try_catch(|| {
    let _tmp = BailoutGuard::new(TempFile::create());
    risky_engine_call();
});
// On Err, _tmp is still allocated here. The exported handler frees both
// _tmp and file later, and file dangles.

After (v0.16)

let file = BailoutGuard::new(File::open("data.txt")?);
let result = try_catch(|| {
    let _tmp = BailoutGuard::new(TempFile::create());
    risky_engine_call();
});
// On Err, try_catch already dropped _tmp. file is still valid.

BailoutGuard is no longer Send. Code that moves a guard to another thread does not compile anymore. Keep the guard on the thread that created it, or call into_inner before you send the value.

zend::run_bailout_cleanups is removed. try_catch runs the cleanup itself.

Panics Stay on the Rust Side of the FFI Boundary

A panic that unwinds out of an extern "C" function aborts the process. In v0.15 every generated handler let a panic do exactly that, because try_catch caught the panic only to resume it. v0.16 keeps every panic on the Rust side. See Panics for the full policy.

try_catch reports a panic instead of resuming it

try_catch and try_catch_first return Err(CatchError::Panic(message)) when the closure panics. CatchError::Panic is a new variant, and Error::Catch prints its message.

// v0.15: the panic continued past this line
let result = try_catch(|| risky());

// v0.16
match try_catch(|| risky()) {
    Err(CatchError::Panic(message)) => std::panic::resume_unwind(Box::new(message)),
    Err(CatchError::Bailout) => { /* ... */ }
    Err(_) => { /* ... */ }
    Ok(value) => { /* ... */ }
}

A test that asserts inside a try_catch closure and ignores the result now passes even when the assertion fails. Assert on the result of try_catch.

Embed::eval and Embed::run_script return EmbedError::CatchError(CatchError::Panic(..)) in that case. PhpEvalError gained a Panic(String) variant. Embed::run still resumes a panic, so a test that panics inside Embed::run still fails.

A panic in a handler throws a PHP Error

A panic in a generated function, method, constructor, #[php_impl_interface] method, Closure, property getter or setter, or clone throws a PHP Error with the message Rust panic: <message> and returns normally. A panic in Drop of a Rust-backed object reports an E_WARNING and frees the object. Before v0.16 each of these aborted the PHP process.

A handler that you write by hand for FunctionBuilder::new does not get this behaviour on its own. Run its body through the new zend::run_handler.

PhpException::throw returns ()

throw() no longer returns a Result. If the exception cannot be thrown, for example because the class is an interface or the message contains a NUL byte, throw() throws a plain Error whose message names the cause and contains the original message.

// v0.15
exception.throw()?;
exception.throw().expect("Failed to throw PHP exception.");

// v0.16
exception.throw();

IntoZval for Result<T, E> throws the error as before and now returns Err(Error::ExceptionPending { class }) instead of the conversion error.

MINIT fails instead of panicking

ModuleStartup::startup returns an Err when a class, an interface or an enum cannot be registered. The generated startup function logs the cause as an E_CORE_WARNING and returns FAILURE, and PHP reports Unable to start <extension> module. A panic during registration gets the same treatment. In v0.15 a failed constant registration made the startup function return 1, which is not FAILURE (-1), so PHP started the module anyway.

get_module never panics. If the module cannot be built, the macro returns a placeholder entry whose startup function logs the build error and fails as above.

New items

zend::run_handler runs the body of a hand-written handler with the panic and bailout behaviour of generated code. zend::ce::error returns the Error class entry.

What still aborts

The observer, zend extension, error observer and exception observer hooks, the module globals constructor and destructor, the embed Sapi trampolines, and every extern "C" function that you give to the engine yourself still abort on a panic. This includes the startup function of #[php_module]. BailoutGuard semantics do not change.

Macro Options That Did Nothing Are Now Compile Errors

Several attribute options parsed and then had no effect. Each one is now a compile error that points at the option. The generated PHP API did not change for code that compiled without them.

Before (compiled, no effect)After
#[php_enum(name = "Suit")], #[php_interface(...)], #[php_extern(...)]Error. Write #[php(name = "Suit")] on the item. #[php_extern] takes no options.
#[php_function] + #[php(vis = "private")]Error. PHP functions have no visibility. Remove vis.
#[php_enum] + #[php(vis = "protected")]Error. PHP enums are always public. Remove vis.
#[php(getter)] or #[php(setter)] on a trait methodError. Declare a plain method.
#[php(constructor)] on a trait methodError. This option removed the method from the interface. Declare a plain method.
#[php(prop, default = 0)] on a non-static fieldError. default applies to static properties only. Set the value in the constructor.
#[php(name = "x")] on a field without propError. Add prop, or remove the attribute.
#[php(...)] on a #[php_impl_interface] method, a #[php_extern] function or a #[derive(ZvalConvert)] itemError. These macros accept no #[php] option.
#[php(getter, setter)] on one methodError. Declare one method for each.
Two #[php(getter)] methods, or two #[php(setter)] methods, for one property nameError. Keep one getter and one setter for each property.
#[php(getter)] and #[php(setter)] of one property with a different visError. Give both methods the same vis. The property took the most permissive visibility before.
#[php(defaults(x = 1))] where no parameter is named xError on x. Each key of defaults must name a parameter.
#[php(optional = x)] where no parameter is named xError on x. The required count came from the parameter types before.
#[php(defaults(self_ = ...))] or #[php(optional = self_)] on a method that takes self_: &mut ZendClassObject<T>Error. self_ is the PHP object, not a PHP parameter.
#[php(defaults(...))] or #[php(optional = ...)] on a getter or setter methodError. The method maps to a PHP property, which has no parameters.

Existing diagnostics now point at the offending attribute or item instead of the whole macro call.

Getter and Setter Property Names Are camelCase

A property declared with #[php(getter)] or #[php(setter)] now follows the same naming rule as a #[php(prop)] field. The macro removes the get_ or set_ prefix from the method name and converts the rest to camelCase. Before, the rest of the method name was used as is. The name and change_case options on a getter or setter were ignored before. They now rename the property.

Rust methodBeforeAfter
get_name$obj->name$obj->name
get_first_name$obj->first_name$obj->firstName
#[php(getter, name = "first_name")] fn get_first_name$obj->first_name$obj->first_name

To keep the old name, add #[php(name = "first_name")] or #[php(change_case = "snake_case")] to the getter and to the setter.

A Field Property and a Method Property Cannot Share a Name

Before, a #[php(getter)] method with the same property name as a #[php(prop)] field was registered a second time, and reads used the field. The guide said that the method took precedence. Now ClassBuilder::register returns Error::DuplicateProperty and the extension does not load. Remove the #[php(prop)] attribute from the field, or rename one of the two properties with #[php(name = "...")].

Class Constant Visibility

Constants inside a #[php_impl] block accept #[php(vis = "public" | "protected" | "private")]. The default stays public. vis on a #[php_const] or on an interface constant is a compile error.

The manual registration API carries the visibility:

Before (v0.15)

builder.constant("MAX", 10, &[])?;
builder.dyn_constant("MAX", &10, &[])?;

fn constants() -> &'static [(&'static str, &'static dyn IntoZvalDyn, DocComments)] {
    &[("MAX", &10, &[])]
}

After (v0.16)

use ext_php_rs::flags::ConstantFlags;

builder.constant("MAX", 10, &[], ConstantFlags::Public)?;
builder.dyn_constant("MAX", &10, &[], ConstantFlags::Public)?;

fn constants() -> &'static [(&'static str, &'static dyn IntoZvalDyn, DocComments, ConstantFlags)] {
    &[("MAX", &10, &[], ConstantFlags::Public)]
}

ConstantFlags::Promoted is removed. ZEND_ACC_PROMOTED is a property flag, and on PHP 8.1 to 8.4 its bit means final in the constant column.

ext_php_rs_introspection::Constant gains a visibility field, and the crate moves to 0.2.0. Stubs print public const for class constants. Rebuild cargo-php with cargo install cargo-php --force, because the 0.1 binary refuses an extension built with 0.2.

Optional Nullable Arguments Report a null Default

An Option<T> argument in trailing position is optional and nullable. Before v0.16, the extension registered it without a default value. ReflectionParameter::isOptional() returned true and isDefaultValueAvailable() returned false. PHP code cannot produce this combination, and the generated stub already printed = null.

The argument info now carries null as the default value. This is what PHP declares for ?int $x = null. An explicit #[php(defaults(x = ...))] does not change.

Argument Metadata Comes From the Type

The macros decided nullability, pass-by-reference and variadics by comparing type names as text. Option was matched by its last path segment, the variadic slice by the exact spelling &[&Zval], and PhpRef by its name. A type alias or a qualified path produced a wrong PHP signature.

The metadata now comes from constants on the extraction traits. FromZval and FromZvalMut have NULLABLE, FromZvalMut has BY_REF, and both have from_missing(), the value used when the caller omits the argument. Custom implementations do not have to change: the constants default to false and from_missing() defaults to None. Arg::of::<T>(name) builds an Arg from those constants. FunctionBuilder::required_args(n) and ArgParser::required_args(n) set the required count directly; the generated code computes it with args::required_count.

Type aliases are honored

A parameter declared with type MaybeAge = Option<i64> or with std::option::Option<i64> is now nullable and optional. Before, it was a required int, and a debug build of PHP stopped with “Arginfo / zpp mismatch” when the caller passed null. If you relied on the old signature, spell the type without Option.

Object arguments are passed by value

&mut T for a registered class, &mut ZendClassObject<T> and &mut ZendObject no longer set ZEND_SEND_BY_REF. PHP objects are handles, so the callee still mutates the caller’s object. Callers can now write process(new Foo()), which used to fail with “could not be passed by reference”. ReflectionParameter::isPassedByReference() returns false for these parameters. &mut Zval, PhpRef and &mut ZendHashTable keep the flag.

Option<&mut T> object arguments

Option<&mut T> for a class type does not compile any more. Use &mut T: the argument is a handle, and a nullable object argument that is also pass-by-reference has no meaning. Option<&mut ZendHashTable> keeps working as a nullable pass-by-reference array.

Variadic parameters accept any element type

The variadic tail is any &[T] slice in last position, for example &[i64]. A variadic parameter followed by another parameter is a compile error.

Default values are rendered from the value

The stub text of a #[php(defaults(...))] value was produced by matching the Rust expression as text, so String::from("x") and std::string::String::from("x") rendered differently. The generated code now converts the expression into the parameter type at module load and renders it with the new StubLiteral trait, without touching the engine. The parameter type must implement StubLiteral when it has a default. Scalars, strings, Option<T>, Vec<T>, arrays and HashMap<K, V> implement it. Strings render with single quotes, which PHP reflection reads back as the string value.

Default and Clone are detected through the type system

The class macro looked for the words Default and Clone inside #[derive(...)] attributes. A qualified path such as #[derive(std::default::Default)] or a hand-written impl Clone was not seen, so default_init() returned None and clone failed. The generated RegisteredClass implementation now probes the trait bounds at compile time. Every Default or Clone implementation counts, whatever its spelling.

Parameters After optional Must Accept a Missing Value

#[php(optional = x)] lets PHP callers omit x and every parameter after it. Before v0.16, a parameter in that range could have a type such as i64 with no default. The function compiled, and PHP showed the parameter as optional. When a caller omitted it, the call threw “Invalid value given for argument”.

Each parameter from x to the end must now be an Option<T>, have a default in #[php(defaults(...))], or be the variadic &[T] tail. Otherwise the build fails with an error on the type of that parameter. The macro reads this rule from FromZvalMut::NULLABLE. If you implement FromZval or FromZvalMut, from_missing() must return Some exactly when NULLABLE is true.

// Before: compiled, `describe("Ann")` threw at run time.
#[php_function]
#[php(optional = age)]
pub fn describe(name: String, age: Option<i64>, city: String) -> String

// After: `city` has a default, or is `Option<String>`.
#[php_function]
#[php(optional = age, defaults(city = "\"Paris\""))]
pub fn describe(name: String, age: Option<i64>, city: String) -> String

Interface Methods Keep Their Parameters After self_

A #[php_interface] method whose first parameter is self_: &mut ZendClassObject<T> lost its last parameter instead of self_. PHP declared scale(self_) for fn scale(self_: ..., factor: f64), and a PHP class that implemented scale(float $factor) did not load. The interface now declares scale(float $factor), like #[php_impl] does for methods.

Backed Enums Implement From

#[php_enum] generated impl Into<i64> for E and impl Into<&'static str> for E. It now generates impl From<E> for i64 and impl From<E> for &'static str. Calls to .into() do not change. You can also write i64::from(e).

Renaming Options Are Consistent

The options that set a PHP name now follow one set of rules. Code that used them in a way the guide did not describe is now a compile error or a different PHP name.

Before (v0.15)After (v0.16)
#[php(name = "x", change_case = "snake_case")] compiled and change_case had no effectCompile error. Keep one of the two options.
#[php(rename_cases = "snake_case")] on a #[php_enum]Compile error. Write #[php(change_cases_case = "snake_case")], like change_method_case and change_constant_case.
#[php_impl_interface(change_method_case = "snake_case")] repeated the rule of the traitCompile error. Remove the argument. The class takes each method name from #[php_interface].
#[php_impl_interface] ignored the name or change_case of a trait method, and the trait’s change_method_case. The class registered the method under another name, and new failed with “Cannot instantiate abstract class”The class registers each method under the name of the interface.
A trait method named __to_string in #[php_interface] was declared as toStringIt is declared as __toString, like in #[php_impl]. The other magic method names follow the same rule.
Two methods, two constants or two enum cases of one #[php_impl], #[php_interface] or #[php_enum] got the same PHP name. PHP then dropped every method of the class, or stopped at startup with “Cannot redefine class constant”Compile error at the second item. Rename one of them.
Two methods of one class from different blocks, two module functions, or two classes got the same PHP name. PHP dropped the methods of the class, did not load the extension, or replaced the first class without a messageClassBuilder::register, EnumBuilder::register and the module build return Error::DuplicateMethod, Error::DuplicateConstant, Error::DuplicateFunction or Error::DuplicateClass, and the extension does not start. Rename one of them.

Migrating to v0.14

New Macro Transition

The old macro system used a global state to be able to automatically register functions and classes when the #[php_module] attribute is used. However, global state can cause problems with incremental compilation and is not recommended.

To solve this, the macro system has been re-written but this will require changes to user code. This document summarises the changes.

There is no real changes on existing macros, however you will now need to register functions, classes, constants and startup function when declaring the module.

#[php_module]
#[php(startup = "startup_function")]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<TestClass>()
        .function(wrap_function!(hello_world))
        .constant(wrap_constant!(SOME_CONSTANT))
}

Functions

Mostly unchanged in terms of function definition, however you now need to register the function with the module builder:

use ext_php_rs::prelude::*;

#[php_function]
pub fn hello_world() -> &'static str {
    "Hello, world!"
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .function(wrap_function!(hello_world))
}

Supported #[php] attributes:

  • #[php(name = "NEW_NAME")] - Renames the function
  • #[php(change_case = case)] - Changes the case of the function name
  • #[php(vis = "public")] - Changes the visibility of the function
  • #[php(defaults(a = 5, test = 100))] - Sets default values for function arguments
  • #[php(variadic)] - Marks the function as variadic. The last argument must be a &[&Zval]

Classes

Mostly unchanged in terms of the class and impl definitions, however you now need to register the classes with the module builder:

use ext_php_rs::prelude::*;

#[php_class]
#[derive(Debug)]
pub struct TestClass {
    #[php(prop)]
    a: i32,
    #[php(prop)]
    b: i32,
}

#[php_impl]
impl TestClass {
    #[php(name = "NEW_CONSTANT_NAME")]
    pub const SOME_CONSTANT: i32 = 5;
    pub const SOME_OTHER_STR: &'static str = "Hello, world!";

    pub fn __construct(a: i32, b: i32) -> Self {
        Self { a: a + 10, b: b + 10 }
    }

    #[php(defaults(a = 5, test = 100))]
    pub fn test_camel_case(&self, a: i32, test: i32) {
        println!("a: {} test: {}", a, test);
    }

    fn x(&self) -> i32 {
        5
    }

    pub fn builder_pattern(
        self_: &mut ZendClassObject<TestClass>,
    ) -> &mut ZendClassObject<TestClass> {
        dbg!(self_)
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<TestClass>()
}

Supported #[php] attributes (struct):

  • #[php(name = "NEW_NAME")] - Renames the class
  • #[php(change_case = case)] - Changes the case of the class name
  • #[php(vis = "public")] - Changes the visibility of the class
  • #[php(extends(ce = ce_fn, stub = "ParentClass")] - Extends a parent class
  • #[php(implements(ce = ce_fn, stub = "Interface"))] - Implements an interface
  • #[php(prop)] - Marks a field as a property

Supported #[php] attributes (impl):

  • #[php(change_constant_case = case)] - Changes the case of the constant names. Can be overridden by attributes on the constants.
  • #[php(change_method_case = case)] - Changes the case of the method names. Can be overridden by attributes on the methods.

For elements in the #[php_impl] block see the respective function and constant attributes.

Extends and Implements

Extends and implements are now taking a second parameter which is the stub name. This is the name of the class or interface in PHP.

This value is only used for stub generation and is not used for the class name in Rust.

Constants

Mostly unchanged in terms of constant definition, however you now need to register the constant with the module builder:

use ext_php_rs::prelude::*;

#[php_const]
const SOME_CONSTANT: i32 = 100;

#[php_const]
#[php(name = "HELLO_WORLD")]
const SOME_OTHER_CONSTANT: &'static str = "Hello, world!";

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .constant(wrap_constant!(SOME_CONSTANT)) // SOME_CONSTANT = 100
        .constant(wrap_constant!(SOME_OTHER_CONSTANT)) // HELLO_WORLD = "Hello, world!"
        .constant(("CONST_NAME", SOME_CONSTANT, &[])) // CONST_NAME = 100
}

Supported #[php] attributes:

  • #[php(name = "NEW_NAME")] - Renames the constant
  • #[php(change_case = case)] - Changes the case of the constant name
  • #[php(vis = "public")] - Changes the visibility of the constant

Extern

No changes.

use ext_php_rs::prelude::*;

#[php_extern]
extern "C" {
    fn phpinfo() -> bool;
}

fn some_rust_func() {
    let x = unsafe { phpinfo() };
    println!("phpinfo: {x}");
}

Startup Function

The #[php_startup] macro has been deprecated. Instead, define a function with the signature fn(ty: i32, mod_num: i32) -> i32 and provide the function name

use ext_php_rs::prelude::*;

fn startup_function(ty: i32, mod_num: i32) -> i32 {
    0
}

#[php_module]
#[php(startup = "startup_function")]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
}

#[php] Attributes

Attributes like #[rename] or #[prop] have been moved to the #[php] attribute.

The #[php] attribute on an item are combined with each other. This means that the following variants are equivalent:

#[php(change_case = case)]
#[php(vis = "public")]
#[php(change_case = case, vis = "public")]

Renaming and Case Changes

Default case was adjusted to match PSR standards:

  • Class names are now PascalCase
  • Property names are now camelCase
  • Method names are now camelCase
  • Constant names are now UPPER_CASE
  • Function names are now snake_case

This can be changed using the change_case attribute on the item. Additionally, the change_method_case and change_constant_case attributes can be used to change the case of all methods and constants in a class.

name vs change_case

Previously the (re)name parameter was used to rename items. This has been unified to use name to set the name of an item to a string literal. The change_case parameter is now used to change the case of the name.

#[php(name = "NEW_NAME")]
#[php(change_case = snake_case)]]

Available cases are:

  • snake_case
  • PascalCase
  • camelCase
  • UPPER_CASE
  • none - No change