PHP FFI and Rust: Calling Native Code Without Writing a Zend Extension
PHP has a well-known ceiling. Once a request spends its time in tight numeric loops, byte-level string work, or anything that hammers the CPU rather than the database, the interpreter becomes the bottleneck and no amount of caching moves the needle. The traditional escape hatch was a Zend extension written in C — an approach that means learning a large internal API, managing reference counts by hand, and recompiling against every PHP minor version you support.
FFI, shipped in PHP 7.4, offers a different trade. You declare a C function signature in a string, point PHP at a shared library, and call it. No Zend API, no per-version recompilation of PHP glue. Pair that with Rust — which compiles to a plain C-ABI shared object and refuses to let you write most of the memory bugs C invites — and you get native speed with a boundary you can reason about.
The catch is that FFI moves the danger rather than removing it. Everything Rust guarantees inside its own crate stops at the extern "C" line. What follows is the part of the picture that gets skipped: what the boundary actually costs, who owns which allocation, and what happens when Rust panics inside a PHP-FPM worker.
What the Boundary Costs
Every FFI call does real work before your Rust code runs. PHP marshals arguments into a C-compatible layout, resolves the symbol through libffi, performs the call, and converts the return value back into a CData handle. That overhead is small in absolute terms but it is not free, and it is meaningfully larger than the dispatch cost of a compiled Zend extension, which the engine calls almost directly.
The practical consequence is a rule you should treat as non-negotiable: FFI wins on batch size, not on raw function speed. A Rust function that runs in 200 nanoseconds, called a million times in a loop from PHP, will be slower than the equivalent pure-PHP loop, because you pay the crossing cost a million times and it dwarfs the work. The same Rust function, called once with a million items and looping internally, is a large win.
This reframes the design question. You are not looking for slow PHP functions to replace one-for-one. You are looking for slow PHP loops to replace with a single crossing.
| Workload shape | FFI verdict | Why |
|---|---|---|
| One call, large data set (image decode, bulk distance matrix) | Strong fit | Crossing cost amortized across the whole batch |
| Tight loop calling a tiny function per iteration | Avoid | Per-call overhead exceeds the work being done |
| Existing C library you must integrate (libsodium, a vendor SDK) | Strong fit | No realistic alternative short of a full extension |
| I/O-bound work (HTTP, database, disk) | Pointless | The bottleneck is not CPU; use async PHP instead |
| Logic you could vectorize with existing array functions | Avoid | Native PHP array functions already run in C |
That last row deserves emphasis. Before reaching for Rust, check whether a built-in already does the job in C. If the work is genuinely concurrency-bound rather than CPU-bound, the fix is a different execution model — PHP Fibers and async programming address a problem FFI cannot touch.
The Rust Side: Producing a C-Compatible Library
Rust needs two things to be callable from PHP: a cdylib crate type, and functions exported with the C ABI and an unmangled symbol name.
[package] name = "phpaccel" version = "0.1.0" edition = "2021" [lib] name = "phpaccel" # cdylib produces libphpaccel.so (Linux) / .dylib (macOS) with a plain C ABI. # "rlib" or the default "lib" produce Rust-only artifacts PHP cannot load. crate-type = ["cdylib"] [profile.release] opt-level = 3 lto = true codegen-units = 1 # Deliberately NOT setting panic = "abort". # Abort would kill the entire PHP-FPM worker on a Rust panic. # We catch panics at the boundary instead - see below.
For the example, take a workload PHP is genuinely bad at: fuzzy-matching a search term against a large candidate list using Levenshtein distance. PHP ships levenshtein(), but it caps operand length at 255 bytes and you still pay interpreter overhead per comparison across the loop.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::panic::{catch_unwind, AssertUnwindSafe};
/// Levenshtein distance over raw bytes using a rolling two-row DP buffer.
/// Allocates O(min(a,b)) rather than the O(a*b) full matrix.
fn levenshtein(a: &[u8], b: &[u8]) -> usize {
if a.is_empty() { return b.len(); }
if b.is_empty() { return a.len(); }
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1) // deletion
.min(curr[j] + 1) // insertion
.min(prev[j] + cost); // substitution
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
/// Scan `count` C strings and return the index of the closest match to
/// `needle`, writing the winning distance into `out_distance`.
///
/// Returns: >= 0 index on success, -1 on bad input, -2 if Rust panicked.
///
/// # Safety
/// `candidates` must point to `count` valid, NUL-terminated C strings that
/// stay alive for the duration of this call.
#[no_mangle]
pub extern "C" fn pa_best_match(
needle: *const c_char,
candidates: *const *const c_char,
count: usize,
out_distance: *mut usize,
) -> i64 {
let outcome = catch_unwind(AssertUnwindSafe(|| {
if needle.is_null() || candidates.is_null() || count == 0 {
return -1i64;
}
let needle_bytes = unsafe { CStr::from_ptr(needle) }.to_bytes();
let list = unsafe { std::slice::from_raw_parts(candidates, count) };
let mut best_index: i64 = -1;
let mut best_distance = usize::MAX;
for (i, &item) in list.iter().enumerate() {
if item.is_null() { continue; }
let candidate = unsafe { CStr::from_ptr(item) }.to_bytes();
let distance = levenshtein(needle_bytes, candidate);
if distance < best_distance {
best_distance = distance;
best_index = i as i64;
if distance == 0 { break; } // exact hit, stop scanning
}
}
if best_index >= 0 && !out_distance.is_null() {
unsafe { *out_distance = best_distance; }
}
best_index
}));
// A panic must never unwind across the FFI boundary - that is undefined
// behaviour. Convert it into an error code the caller can check.
outcome.unwrap_or(-2)
}
/// Lowercase and trim a string, returning a Rust-allocated C string.
/// The caller MUST return the pointer to pa_string_free.
#[no_mangle]
pub extern "C" fn pa_normalize(input: *const c_char) -> *mut c_char {
let outcome = catch_unwind(AssertUnwindSafe(|| {
if input.is_null() { return std::ptr::null_mut(); }
let text = unsafe { CStr::from_ptr(input) }.to_string_lossy().to_lowercase();
match CString::new(text.trim()) {
Ok(s) => s.into_raw(), // ownership transfers to the caller
Err(_) => std::ptr::null_mut(),
}
}));
outcome.unwrap_or(std::ptr::null_mut())
}
/// Reclaim a pointer produced by pa_normalize. Passing anything else is UB.
#[no_mangle]
pub extern "C" fn pa_string_free(ptr: *mut c_char) {
if ptr.is_null() { return; }
unsafe { drop(CString::from_raw(ptr)); }
}Three details in that code matter more than they look. #[no_mangle] stops Rust from decorating the symbol name, so dlsym can find pa_best_match verbatim. extern "C" selects the platform C calling convention instead of Rust’s unstable internal one. And every exported function returns a sentinel value rather than a Result, because Rust enums have no stable C representation.
Who Frees What
Memory ownership is where FFI integrations leak, and the rule is simple to state: whichever side allocated the memory must free it, using its own allocator. Rust’s allocator and PHP’s emalloc are different heaps. Calling free() on a Rust-allocated pointer, or letting Rust reclaim a PHP buffer, corrupts the heap — often not at the point of the mistake, which is what makes it miserable to debug.
That is why pa_normalize is paired with pa_string_free. Rust hands out a pointer via CString::into_raw, which deliberately forgets the allocation so the destructor does not run. The only correct way to reclaim it is CString::from_raw on the Rust side.
<?php
declare(strict_types=1);
final class FuzzyMatcher
{
private const HEADER = <<<'C'
long pa_best_match(const char *needle, const char **candidates,
size_t count, size_t *out_distance);
char * pa_normalize(const char *input);
void pa_string_free(char *ptr);
C;
private FFI $ffi;
public function __construct(string $libraryPath)
{
if (!extension_loaded('FFI')) {
throw new RuntimeException('ext-ffi is not loaded');
}
// 'long' above assumes an LP64 platform (64-bit Linux/macOS), where
// long and Rust's i64 are both 8 bytes. On 64-bit Windows long is
// 4 bytes - declare int64_t there instead.
$this->ffi = FFI::cdef(self::HEADER, $libraryPath);
}
/**
* @param list<string> $candidates
* @return array{index:int,value:string,distance:int}|null
*/
public function bestMatch(string $needle, array $candidates): ?array
{
$values = array_values($candidates);
$count = count($values);
if ($count === 0) {
return null;
}
// owned=false means PHP will NOT garbage collect these; we free them
// ourselves in the finally block. Owned buffers can be reclaimed while
// Rust still holds the pointer, which is a use-after-free.
$pointers = FFI::new("char*[{$count}]", false);
$distance = FFI::new('size_t', false);
$buffers = [];
try {
foreach ($values as $i => $candidate) {
$buffers[$i] = $this->toCString($candidate);
$pointers[$i] = FFI::cast('char*', $buffers[$i]);
}
$needleBuffer = $this->toCString($needle);
$buffers[] = $needleBuffer;
$index = $this->ffi->pa_best_match(
FFI::cast('char*', $needleBuffer),
FFI::cast('char**', $pointers),
$count,
FFI::addr($distance)
);
if ($index === -2) {
throw new RuntimeException('Rust panicked inside pa_best_match');
}
if ($index < 0) {
return null;
}
return [
'index' => (int) $index,
'value' => $values[$index],
'distance' => (int) $distance->cdata,
];
} finally {
foreach ($buffers as $buffer) {
FFI::free($buffer);
}
FFI::free($pointers);
FFI::free($distance);
}
}
public function normalize(string $input): string
{
$ptr = $this->ffi->pa_normalize($input);
if ($ptr === null) {
throw new RuntimeException('pa_normalize returned NULL');
}
try {
// Copy into a PHP string BEFORE handing the pointer back to Rust.
return FFI::string($ptr);
} finally {
$this->ffi->pa_string_free($ptr);
}
}
private function toCString(string $value): FFI\CData
{
$length = strlen($value) + 1; // room for the NUL byte
$buffer = FFI::new("char[{$length}]", false);
FFI::memcpy($buffer, $value, strlen($value)); // FFI::new zeroes memory,
return $buffer; // so the last byte is NUL
}
}The finally blocks are not defensive decoration. If pa_best_match throws partway through, every buffer allocated with owned=false leaks for the lifetime of the worker process — and in a long-lived PHP-FPM or RoadRunner worker, that leak accumulates across thousands of requests rather than vanishing at the end of one.
Panics Are Not Exceptions
A Rust panic unwinding across an extern "C" boundary is undefined behaviour. Not “throws an error PHP can catch” — undefined, as in the process may corrupt its own stack. Modern Rust inserts an abort at the boundary to stop the unwind, which turns a recoverable bug into an immediately dead worker.
The catch_unwind wrapper in every exported function above exists specifically to prevent this. It converts a panic into a sentinel return value that the PHP wrapper checks and turns into a normal exception. Two things are required for it to work: the crate must not be compiled with panic = "abort", and every single exported function needs the wrapper. One unwrapped function is enough to take down the worker.
Watch for the panics you did not write. Slice indexing, integer overflow in debug builds, unwrap() on a None, and allocation failure all panic. The Rust FFI documentation in the Rustonomicon’s FFI chapter is the reference worth reading closely before shipping.
Preloading Is the Production Configuration
The ffi.enable directive has three settings, and the middle one is the only sensible production choice:
ffi.enable=false— FFI is unavailable everywhere. The default posture.ffi.enable=preload— FFI is usable only from files listed inopcache.preload. Application code can call the wrapper class but cannot open arbitrary shared libraries.ffi.enable=true— any script can load any.soon the filesystem. A single file-upload or template-injection bug becomes arbitrary native code execution.
Preload mode also removes a per-request cost. Parsing C declarations and resolving symbols happens once at server startup instead of on every request. The mechanism is FFI::load() against a header file carrying two special defines, with FFI::scope() retrieving the already-parsed binding later.
/* phpaccel.h - parsed once during opcache preload */
#define FFI_LIB "/usr/local/lib/libphpaccel.so"
#define FFI_SCOPE "PHPACCEL"
long pa_best_match(const char *needle, const char **candidates,
size_t count, size_t *out_distance);
char * pa_normalize(const char *input);
void pa_string_free(char *ptr);<?php // preload.php - referenced by opcache.preload in php.ini // Parse the header and bind the library once, at startup. FFI::load(__DIR__ . '/phpaccel.h'); // Preload the wrapper class itself so requests do not re-compile it. opcache_compile_file(__DIR__ . '/src/FuzzyMatcher.php');
<?php
// In request code, retrieve the preloaded binding by scope name.
// No file access, no C parsing, no symbol resolution per request.
$accel = FFI::scope('PHPACCEL');
$distance = FFI::new('size_t', false);
// ... same calling pattern as beforeThe php.ini side needs opcache.preload=/path/to/preload.php, opcache.preload_user set to the PHP-FPM user, and ffi.enable=preload. Preloading interacts closely with the rest of your opcode cache configuration; the tuning details are covered in the PHP opcode cache performance guide. Full parameter documentation lives in the official PHP FFI manual.
One operational gotcha: preloaded libraries are resolved at startup, so replacing the .so file on disk does not take effect until PHP-FPM restarts. Deployment scripts that swap the library without a reload will silently keep running the old code.
Measure, Do Not Assume
Published FFI benchmarks are close to useless for your decision, because the answer depends entirely on your batch size and the cost of your specific function. The number you need is the break-even point: the batch size above which crossing the boundary pays for itself.
<?php
// Find the break-even batch size for your workload and hardware.
$matcher = new FuzzyMatcher('/usr/local/lib/libphpaccel.so');
$dictionary = file(__DIR__ . '/words.txt', FILE_IGNORE_NEW_LINES);
foreach ([1, 10, 100, 1_000, 10_000, 100_000] as $batchSize) {
$slice = array_slice($dictionary, 0, $batchSize);
$start = hrtime(true);
$matcher->bestMatch('recieve', $slice);
$ffiNs = hrtime(true) - $start;
$start = hrtime(true);
$best = PHP_INT_MAX;
foreach ($slice as $word) {
$d = levenshtein('recieve', $word); // capped at 255 bytes per operand
if ($d < $best) { $best = $d; }
}
$phpNs = hrtime(true) - $start;
printf(
"batch %7d | ffi %9.3f ms | php %9.3f ms | speedup %5.2fx%s",
$batchSize, $ffiNs / 1e6, $phpNs / 1e6, $phpNs / $ffiNs, PHP_EOL
);
}Run that and the shape is consistent even though the exact figures are not: at a batch size of one, FFI loses. Somewhere in the hundreds to low thousands the lines cross. Above that, the gap widens steadily until the Rust implementation is doing essentially all the work and PHP is just handing it a pointer.
Benchmark the wrapper, not the raw call. The buffer allocation and copying loop inside bestMatch is genuine work, and on large batches it can rival the Rust computation itself. If that copying dominates, the fix is to change the interface — pass a single flat, NUL-delimited buffer instead of an array of pointers, and let Rust split it.
Before You Ship
A short list of the things that actually go wrong in production:
- Version the ABI. Export a
pa_abi_version()function and check it on startup. A library rebuilt with a changed struct layout, deployed against old PHP declarations, produces silent garbage rather than an error. - Ship the right binary. A
cdylibbuilt on Alpine against musl will not load on a Debian glibc image. Build inside the same base image you deploy. - Keep the declarations and the header in one file. Duplicating signatures between
FFI::cdefstrings and a.hfile guarantees they drift. Use the header as the single source and load it. - Set
ffi.enable=preload, nevertrue. This is a security boundary, not a performance tweak. - Test the failure paths. Write a test that deliberately triggers the panic branch and asserts the worker survives it.
FFI is worth reaching for when you have measured a CPU-bound hot spot, confirmed it batches well, and accepted that you now maintain a compiled artifact in your deployment pipeline. That last cost is the one teams underestimate. If the win is a few percent, it is not worth a cross-compilation toolchain in CI. If it is an order of magnitude on a core workload, the boundary is easy to justify — as long as you respect it.
editor's pick
latest video
news via inbox
Nulla turp dis cursus. Integer liberos euismod pretium faucibua

