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
IntoZvalandFromZvalfor 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
- This guide!
- Rust docs
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:
- The PHP CLI executable itself
- The PHP development headers
- The
php-configbinary
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.exeis 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
Sbe the empty string - append to
Sthe value of std::env::consts::DLL_PREFIX (empty on windows,libon unixes) - append to
Sthe lower-snake-case version of your crate name - append to
Sthe value of std::env::consts::DLL_SUFFIX (.dllon windows,.dylibon macOS,.soon 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 (
Stringand&str) Vec<T>where T implementsIntoZvaland/orFromZval.HashMap<String, T>where T implementsIntoZvaland/orFromZval.Binary<T>where T implementsPack, used for transferring binary string data.BinarySlice<T>where T implementsPack, used for exposing PHP binary strings as read-only slices.- A PHP callable closure or function wrapped with
Callable. Option<T>where T implementsIntoZvaland/orFromZval, and whereNoneis converted to a PHPnull.
Return types can also include:
- Any class type which implements
RegisteredClass(i.e. any struct you have registered with PHP). - An immutable reference to
selfwhen used in a method, through theClassReftype. - A Rust closure wrapped with
Closure. Result<T, E>, whereT: IntoZvalandE: Into<PhpException>. When the error variant is encountered, it is converted into aPhpExceptionand 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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | i32 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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | zend_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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| No | Yes | No | Yes | zend_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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | Union 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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | ZendHashTable |
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 tableEntry::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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | zend_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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | No | No | zend_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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| Yes | No | Yes | No | Depends 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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
| No | Yes | ZBox<ZendObject> | Yes, mutable only | Zend 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:
| Method | Description |
|---|---|
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.
-
closurefeature required: Themake_lazy_ghost()andmake_lazy_proxy()methods require theclosurefeature 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()andmake_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 parameter | T Return type | &T Return type | PHP 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 parameter | T Return type | &T Return type | PHP representation |
|---|---|---|---|---|
Callable | No | Closure, Callablefor PHP functions | No | Callables 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
| Type | PHP syntax | Modifies caller? | Use case |
|---|---|---|---|
Separated | foo($x) or foo([1,2]) | No | Mutate a local copy (COW) |
PhpRef | foo(&$x) | Yes | Modify the caller’s variable |
&Zval | foo($x) | No | Read-only access |
&mut Zval | foo(&$x) | Yes | Legacy, prefer PhpRef |
&mut ZendHashTable | foo(&$x) | Yes | Mutate the caller’s array in place |
&mut MyClass | foo($x) or foo(new MyClass) | Yes | Objects 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 Rustimplblock 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 annotateexternblocks which are deemed as PHP functions.php_interface- Attribute used to export Rust Trait as PHP interfacephp- 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.
- Used by the
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 likeIterator,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
-
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. -
Interface registration: The interface must be registered in the
#[php_module]function using.interface::<PhpInterfaceName>(). -
Link-time discovery: The
inventorycrate uses link-time registration for interface discovery, so all implementations are automatically discovered when the final binary is linked. -
Method names: Each method gets the PHP name that
#[php_interface]declared for it. The trait’schange_method_caseand thenameorchange_caseof 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 usingClassFlags, 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 implementRegisteredClass. - Explicit form:
#[php(extends(ce = ce_fn, stub = "ParentClass"))]- For built-in PHP classes.ce_fnmust be a function with the signaturefn() -> &'static ClassEntry.
- Simple type form:
#[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 implementRegisteredClass. - Explicit form:
#[php(implements(ce = ce_fn, stub = "InterfaceName"))]— For built-in PHP interfaces.ce_fnmust be a valid function with the signaturefn() -> &'static ClassEntry.
- Simple type form:
- Generated stubs write each parent class and interface name as a fully qualified
name, with a leading
\. A leading\instub = "..."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.==andforeach, 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'] = 1and$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 Animalistrue
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 thePHPenv var)PHPInfo::get()- Runsphp -iand parses the outputApiVersion- Enum representing PHP versions (Php80, Php81, Php82, etc.)emit_php_cfg_flags()- Emitscargo:rustc-cfg=phpXXfor all supported versionsemit_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 anOption<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 thePascalCasename 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 nonameorchange_caseof 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 thePascalCasename 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
Zvalcould 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:
| Attribute | const | fn | struct | struct field | impl | impl const | impl fn | enum | enum 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_casePascalCasecamelCaseUPPER_CASEnone- 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, throughzend::run_handler. Closurecalls.- Property getters and setters,
isset(), the(array)cast,var_exporton a Rust-backed object, andclone.
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
Dropthat 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
Sapitrampolines. 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, thestartupfunction of#[php_module], stream wrapper operations, and a handler that you write by hand forFunctionBuilder::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!andphp_println!will panic if the string contains NUL bytes (\0). For binary-safe output, usephp_output!orphp_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:
| Function | Binary-Safe | Output Buffering | Description |
|---|---|---|---|
zend::printf() | No | Yes | Printf-style output (used by php_print!) |
zend::output_write() | Yes | Yes | Binary-safe buffered output |
zend::write() | Yes | No | Binary-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
| Macro | Binary-Safe | Output Buffering | Supports Formatting |
|---|---|---|---|
php_print! | No | Yes | Yes |
php_println! | No | Yes | Yes |
php_output! | Yes | Yes | No |
php_write! | Yes | No | No |
When to Use Each
-
php_print!/php_println!: Use for text output with format strings, similar to Rust’sprint!andprintln!. 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 value | Result | Copy |
|---|---|---|
Cow::Borrowed(input) or &input[..n] | PHP sends the start of the input. | No |
Other Cow::Borrowed slice | PHP sends the slice. | One |
Cow::Owned(vec) | PHP sends the new bytes. | One |
| An empty slice | PHP 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_sizebytes. Achunk_sizeof0sets no limit. - When userland calls
ob_flush(),ob_clean()orob_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.
| Function | PHP 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
SapiGlobalsguard 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_deactivatehooks to mimicphp_rinit/php_rshutdownbehavior. However, superglobals are inaccessible duringsapi_activateand will cause crashes if accessed at that point.
Accessing HTTP Superglobals
The ProcessGlobals type provides access to the common HTTP superglobals:
| Method | PHP 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
| Method | Description |
|---|---|
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 aGlobalReadGuard<ProcessGlobals>ExecutorGlobals::get()returns aGlobalReadGuard<ExecutorGlobals>SapiGlobals::get()returns aGlobalReadGuard<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
| Type | Use Case |
|---|---|
ProcessGlobals | HTTP superglobals ($_GET, $_POST, $_COOKIE, etc.) |
SapiGlobals | Low-level request info, headers |
ExecutorGlobals | Constants, 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.
BailoutGuard::newmoves the value to the heap. The value survives thelongjmp.BailoutGuard::newregisters a cleanup entry with the innermosttry_catchframe.- When you drop the guard, the guard releases the entry and drops the value.
- When a bailout occurs, the
try_catchframe that catches it drops the guards that were created inside its closure, newest first. Then it returnsErr(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
| Method | Description |
|---|---|
should_observe(&self, info: &FcallInfo) -> bool | Called 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
| Field | Type | Description |
|---|---|---|
function_name | Option<&str> | Function name (None for anonymous/main) |
class_name | Option<&str> | Class name for methods |
filename | Option<&str> | Source file (None for internal functions) |
lineno | u32 | Line number (0 for internal functions) |
is_internal | bool | True 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
| Method | Description |
|---|---|
should_observe(&self, error_type: ErrorType) -> bool | Filter 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
| Field | Type | Description |
|---|---|---|
error_type | ErrorType | The error level/severity |
filename | Option<&str> | Source file where error occurred |
lineno | u32 | Line number |
message | &str | The 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
| Method | Description |
|---|---|
on_exception(&self, exception: &ExceptionInfo) | Called when an exception is thrown, before any catch blocks. |
ExceptionInfo - Exception Metadata
| Field | Type | Description |
|---|---|---|
class_name | String | Exception class name (e.g., “RuntimeException”) |
message | Option<String> | The exception message |
code | i64 | The exception code |
file | Option<String> | Source file where thrown |
line | u32 | Line 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
| Field | Type | Description |
|---|---|---|
function | Option<String> | Function name (None for main script) |
class | Option<String> | Class name for method calls |
file | Option<String> | Source file |
line | u32 | Line 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
| Method | Enables | Cost when enabled |
|---|---|---|
hook_op_array_compile() | on_op_array_compiled | One callback per compiled function |
hook_statements() | on_statement | Extra ZEND_EXT_STMT opcode on every statement of every compiled script |
hook_fcalls() | on_fcall_begin / on_fcall_end | Extra 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
| Feature | Observer API (FcallObserver) | Zend Extension (ZendExtensionHandler) |
|---|---|---|
| Function call hooks | begin / end with return value | on_fcall_begin / on_fcall_end (legacy) |
| Filtering | should_observe (cached per function) | No built-in filtering |
| Statement-level hooks | Not available | on_statement |
| Bytecode access | Not available | on_op_array_compiled, on_op_array_ctor, on_op_array_dtor |
| Request lifecycle | Not available | on_activate / on_deactivate |
| Best for | Function-level profiling, tracing | Statement-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
-
Keep observers lightweight: Observer methods are called frequently. Avoid heavy computations or I/O.
-
Use filtering wisely:
should_observeresults are cached for fcall observers. For error observers, filter early to avoid unnecessary processing. -
Handle errors gracefully: Don’t panic in observer methods.
-
Consider memory usage: Implement limits or periodic flushing to avoid unbounded memory growth.
-
Use lazy backtrace: Only call
backtrace()when needed. BothErrorInfoandExceptionInfosupport 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_stringtriggers 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 frominclude_bytes!orinclude_str!. Any type implementingAsRef<[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.
| Input | Handling |
|---|---|
<?php opening tag | Required (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:
| Hook | Use case |
|---|---|
RINIT (request_startup_function) | Per-request setup (classic php-fpm / mod_php) |
MINIT (startup_function) | One-time global setup |
| Custom SAPI callback | Worker-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
-
Build time:
include_bytes!embeds the.phpfile contents into the extension binary as a&[u8]constant. -
Runtime:
php_eval::execute()strips the<?phptag and BOM, then calls two C wrapper functions inwrapper.c:ext_php_rs_zend_compile_string– compiles the source into an op_array. On PHP 8.2+ it passesZEND_COMPILE_POSITION_AFTER_OPEN_TAGso 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.
-
Safety: The entire execution is wrapped in
try_catchto 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:
ServerContext– per-request state (method, URI, POST body, headers).Sapi– the SAPI itself: output, logging, header sending.- 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 prebuiltlib<name>.a.php_<name>.h: declares<name>_module_entryand thephpext_<name>_ptrdefine 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:
-
Build (or install) a PHP with the exact same version, ZTS mode and debug mode as your final binary. Only its
php-configand headers are needed.cd php-src ./buildconf ./configure --enable-zts --disable-all --enable-cli --prefix=$HOME/php-pass1 make -j$(nproc) && make install -
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 -
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/ -
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_extrerunsconfigurewith the flags of the previous invocation plus the new one. -
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 unguardedget_module, as long as the Rust library is built withEXT_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
.ain sync. The static library embeds bindings generated from the pass-1 PHP headers. Rebuild it whenever the target PHP version changes. --enable-<name>=sharedis rejected by the generatedconfig.m4: for shared builds, use the regularcdylibworkflow 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_functionscopies the array only when the types it carries make the engine deriveZEND_ACC_HAS_RETURN_TYPEorZEND_ACC_HAS_TYPE_HINTS, and that copy is shallow:nameanddefault_valuestay borrowed and are read at runtime byReflectionParameter. A function with neither parameters nor a return type keeps the original array. When a SAPI starts the module again in the same process (aFrankenPHPworker 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
nameandversion.module_destructorwalks the table readingfnameafter MSHUTDOWN fordl()-loaded extensions,get_extension_funcs()walks it during a request, and a secondphp_module_startupin 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.defand the CLI SAPI readsdef->valueforphp --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, aOnceLockor 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.
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
IntoZvalimpl ofZBox<ZendObject>orZBox<ZendClassObject<T>>. Do not calldec_countand thenZval::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::AllisE_ALLof the PHP version that you build against. Before PHP 8.4, it containsErrorType::Strict. The old observerALLnever contained it.ErrorType::StrictandErrorType::UserErrorare deprecated on PHP 8.4 and later. The old observerUSER_ERRORwas not deprecated.- The type holds a
u32, not ani32.bits()returns au32, andfrom_bitsandfrom_bits_truncatetake au32.
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_destructorcallszend_unregister_functions(module->functions, …)after the shutdown function returns, and that walks the table readingfname. zend_register_functionscopies thearg_infoarray only shallowly, soarg_info[i].nameand.default_valuestay borrowed by the engine and are read at runtime byReflectionParameter. 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.15 | v0.16 |
|---|---|
Error::InvalidProperty | Error::InvalidProperty { property: String } |
Error::InvalidCString | Error::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 method | Error. Declare a plain method. |
#[php(constructor)] on a trait method | Error. This option removed the method from the interface. Declare a plain method. |
#[php(prop, default = 0)] on a non-static field | Error. default applies to static properties only. Set the value in the constructor. |
#[php(name = "x")] on a field without prop | Error. Add prop, or remove the attribute. |
#[php(...)] on a #[php_impl_interface] method, a #[php_extern] function or a #[derive(ZvalConvert)] item | Error. These macros accept no #[php] option. |
#[php(getter, setter)] on one method | Error. Declare one method for each. |
Two #[php(getter)] methods, or two #[php(setter)] methods, for one property name | Error. Keep one getter and one setter for each property. |
#[php(getter)] and #[php(setter)] of one property with a different vis | Error. Give both methods the same vis. The property took the most permissive visibility before. |
#[php(defaults(x = 1))] where no parameter is named x | Error on x. Each key of defaults must name a parameter. |
#[php(optional = x)] where no parameter is named x | Error 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 method | Error. 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 method | Before | After |
|---|---|---|
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 effect | Compile 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 trait | Compile 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 toString | It 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 message | ClassBuilder::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_casePascalCasecamelCaseUPPER_CASEnone- No change