FFI: Calling Native C Libraries from PHP
Understand what PHP's FFI extension does, load and call functions from a native shared library, work with C structs and pointers from PHP, and recognize the memory-ownership, ABI, deployment, and security concerns that make FFI a tool to reach for rarely and control tightly.
PHP normally talks to the outside world through extensions written in C and compiled into the PHP binary or loaded as .so/.dll modules -- a process that requires compiling C code against PHP's internal APIs. FFI (Foreign Function Interface), available since PHP 7.4, offers a different path: PHP code itself can declare a C library's function signatures and struct layouts, load the shared library at runtime, and call straight into it -- no PHP extension, no compilation step for the PHP side. This unlocks reuse of existing native libraries (image codecs, compression libraries, hardware interfaces, scientific computing code) directly from PHP, but it also hands PHP code the sharp edges of raw C: manual memory management, no bounds checking, and the ability to crash the whole process with a bad pointer. This lesson covers what FFI actually does, how to call C functions and work with structs/pointers, and why its use should be tightly controlled -- often to a small, audited internal library, never to code processing untrusted input.
What FFI Is and How Loading Works
FFI::cdef() takes a string of C declarations (function prototypes, struct definitions, typedefs -- written exactly as they'd appear in a C header) and an optional path to a shared library (.so on Linux, .dll on Windows, .dylib on macOS), and returns an FFI object whose properties and methods mirror those C declarations. Calling a function on that object -- $ffi->some_c_function(...) -- marshals PHP values into their C equivalents, calls directly into the loaded library's compiled machine code, and marshals the C return value back into a PHP value.
There is no source compilation involved on the PHP side; the C declarations you provide are just metadata FFI uses to know how to correctly call into the *already-compiled* shared library and how to interpret its memory layouts. This is fundamentally different from writing a PHP extension: an extension is C code compiled specifically against PHP's internals, whereas FFI lets ordinary, already-existing native libraries -- built with no knowledge of PHP at all -- be called directly.
FFI::load() is a related method that reads declarations from an actual C header file rather than an inline string, which is convenient for larger libraries with existing headers, though in practice complex headers with preprocessor macros or nested includes often need to be hand-trimmed into a simpler declaration set FFI can parse, since FFI's C parser supports a practical subset of the language rather than a full C preprocessor.
• FFI::cdef($declarations, $libraryPath) loads a shared library and exposes its functions/structs to PHP. • No compilation of PHP-side code happens -- declarations are just metadata for calling an already-compiled library. • Different from writing a PHP extension: FFI calls existing native code that knows nothing about PHP. • FFI::load() reads declarations from a real C header file, though complex headers often need trimming.
Calling C Functions and Working with Structs
A simple C function like int add(int a, int b) becomes directly callable as $ffi->add(2, 3) once declared, with PHP ints marshaled to C ints automatically. Return values and simple scalar parameters (int, double, char) map cleanly between PHP and C types; the friction starts with anything involving memory layout -- strings, arrays, structs, and pointers -- because C has no concept of PHP's managed, garbage-collected values.
A C struct declared in the cdef string becomes constructible via $ffi->new('StructName'), producing an FFI\CData object whose fields you read and write like object properties ($point->x = 3; $point->y = 4;), with FFI handling the correct byte layout and alignment matching the C compiler's rules. This CData object can then be passed by reference into functions expecting a pointer to that struct, using FFI::addr() to get its address.
Working with raw pointers means FFI exposes operations C offers with none of PHP's usual safety net: FFI::cast() reinterprets a pointer's type without any runtime check that the reinterpretation is valid, and dereferencing a pointer past the end of its allocated memory reads or writes memory FFI has no way to know is out of bounds -- there is no PHP-level exception for 'you just corrupted adjacent memory,' because at that point you're not really 'in PHP' anymore, you're directly manipulating a memory address the way C code would.
• Scalar types (int, double, char) marshal cleanly between PHP and C automatically. • $ffi->new('StructName') allocates a C struct; its fields behave like PHP object properties with matching byte layout. • FFI::addr($struct) gets a pointer to pass a struct by reference into a C function. • FFI::cast() and raw pointer dereferencing bypass PHP's normal safety checks entirely -- errors here can corrupt memory or crash the process.
Memory Ownership, Callbacks, and ABI Concerns
Memory ownership is the single trickiest part of FFI: when a C function returns a pointer to memory it allocated (malloc'd), PHP has no automatic way to know whether it's now responsible for freeing that memory, whether the C library will free it internally later, or whether the pointer refers to static/global memory that should never be freed at all -- getting this wrong causes either a memory leak (never freeing memory the C side expected you to free) or a use-after-free/double-free crash (freeing memory the C side still owns or already freed). The answer is always specific to the library's documented contract, not something FFI can infer, so every FFI wrapper needs to explicitly encode who owns what and when it gets freed.
FFI supports C function pointers/callbacks -- passing a PHP closure to a C library that expects to call back into it later (common in event-driven or sorting/comparator-style C APIs) -- via FFI::cast() to a function pointer type wrapping a PHP callable. This works but adds another sharp edge: the PHP callback must exactly match the C function pointer's expected signature, and the callback must not throw an uncaught exception across the C call boundary, since unwinding a PHP exception through C stack frames is undefined behavior that can crash the process rather than propagate cleanly.
ABI (Application Binary Interface) concerns matter because FFI's declarations must match the *exact* compiled layout of the target library -- struct field order, padding/alignment, calling convention, and integer sizes (a C 'long' is 4 bytes on Windows but typically 8 bytes on 64-bit Linux) all have to agree between your cdef string and how the actual shared library was compiled. A mismatch doesn't necessarily produce an obvious error; it can silently read/write the wrong struct field or misinterpret a return value, which is far more dangerous than a clean crash because the bug may only surface as subtly wrong data under specific conditions.
• Memory ownership after a C call is never inferred by FFI -- you must know the library's contract (who allocates, who frees, when). • Getting ownership wrong causes leaks (never freed) or use-after-free/double-free crashes (freed at the wrong time). • FFI callbacks let C call back into PHP, but an uncaught PHP exception crossing that boundary is undefined behavior. • ABI mismatches (struct layout, integer sizes, calling convention) between your cdef and the real library can silently corrupt data instead of erroring cleanly.
Deployment, Security, and Why FFI Should Be Tightly Controlled
Deploying FFI-dependent code means shipping and matching two separate artifacts: the PHP application and the exact compiled shared library it targets, for every platform and architecture you deploy to (a .so built for x86-64 Linux won't load on ARM64, and a struct layout that matched one compiler/version might not match another's padding rules). This is meaningfully more deployment surface than pure PHP code, and it's also why FFI usage should generally be wrapped behind a small, deliberately narrow internal PHP API rather than sprinkled through application code -- so the platform-specific fragility is contained in one place.
By default, FFI is restricted to a 'preload' context for performance and security reasons in typical php.ini configurations (ffi.enable can be 'preload', true, or false), meaning ordinary request-time script code often cannot call FFI::cdef() at all unless explicitly enabled -- this default exists because giving arbitrary request-handling code the ability to load arbitrary native libraries and manipulate raw memory is a significant expansion of what a compromised or buggy script can do to the rest of the system.
Because FFI bypasses essentially all of PHP's usual memory and type safety, a bug reachable through FFI (a struct field written from unsanitized user input, an array index derived from a request parameter used as a raw pointer offset) can escalate from 'PHP-level bug' to 'native memory corruption,' which is a categorically more dangerous class of vulnerability -- potentially leading to arbitrary code execution rather than a caught exception. For this reason, FFI code should be small, carefully reviewed, kept far away from directly processing untrusted/user-controlled input, and ideally limited to a narrow, well-tested internal wrapper library rather than general application logic.
• Deploying FFI code means matching a compiled shared library per target platform/architecture -- more deployment surface than pure PHP. • ffi.enable often defaults to restricting FFI to preloaded code, not arbitrary request-time scripts. • FFI bypasses PHP's memory/type safety, so bugs here can become native memory corruption, not just PHP exceptions. • Keep FFI code small, reviewed, and isolated from directly processing untrusted/user input; wrap it in a narrow internal API.
Loading and Calling a C Library
The declaration string must exactly match the target library's real function signatures and struct layout. FFI::addr() takes the address of a CData struct so it can be passed as a pointer parameter. $ffi->new(...) memory is normally owned and freed by FFI/PHP automatically, unlike memory a C function itself malloc's and returns.
$ffi = FFI::cdef(
'int add(int a, int b);' .
'typedef struct { double x; double y; } Point;' .
'double distance(Point *a, Point *b);',
'/usr/lib/libexample.so'
);
echo $ffi->add(2, 3); // 5
$a = $ffi->new('Point');
$a->x = 0.0; $a->y = 0.0;
$b = $ffi->new('Point');
$b->x = 3.0; $b->y = 4.0;
echo $ffi->distance(FFI::addr($a), FFI::addr($b)); // 5.0Code Examples
The FFI object is constructed once (loading and parsing declarations has real overhead) and wrapped behind a small, typed PHP class -- exactly the 'narrow internal API' pattern recommended for FFI usage, so calling code never touches raw FFI objects directly. Constructing the FFI object is comparatively expensive, which is one reason production FFI usage is often paired with php.ini's preload mechanism -- loading and parsing declarations once at server startup instead of on every request.
$this->ffi = FFI::cdef('double clamp(double value, double lo, double hi);', $libraryPath);
$ffi->new('Point') allocates struct memory PHP/FFI manages automatically; FFI::addr() takes its address to pass by pointer, matching the C function signatures exactly. Because translate() receives a real pointer to the struct's memory, its in-place mutation is visible in PHP afterward -- there's no PHP-level copy-on-write happening here the way there would be with a plain PHP array.
$ffi->translate(FFI::addr($point), 1.0, 1.0);
Common mistake: Assuming a CData struct behaves like an ordinary PHP object with value semantics -- passing it by pointer into a C function that mutates it changes the same underlying memory, which is a real, visible side effect unlike passing a plain PHP array by value.
The library's documented contract says make_upper() hands ownership of its returned pointer to the caller, and free_string() is the matching deallocator the library expects you to use -- mixing allocators (e.g. never freeing, or freeing with the wrong function) is exactly the kind of ownership bug FFI cannot catch for you. FFI::string() copies the C string's bytes into an ordinary PHP string before the native memory is freed.
$ffi->free_string($cPtr); // matching deallocator, exactly once
Common mistake: Forgetting to free memory a C function documented as caller-owned (a slow, silent memory leak that only shows up under load), or calling free_string() a second time on the same pointer (a double-free, which can corrupt the heap and crash or, worse, be exploitable).
<?php
declare(strict_types=1);
// Assume libexample.so exports: double clamp(double value, double lo, double hi);
final class NativeMath
{
private readonly FFI $ffi;
public function __construct(string $libraryPath)
{
$this->ffi = FFI::cdef(
'double clamp(double value, double lo, double hi);',
$libraryPath,
);
}
public function clamp(float $value, float $lo, float $hi): float
{
return $this->ffi->clamp($value, $lo, $hi);
}
}
$math = new NativeMath('/usr/lib/libexample.so');
echo $math->clamp(15.0, 0.0, 10.0) . "\n"; // 10.0
echo $math->clamp(-3.0, 0.0, 10.0) . "\n"; // 0.0<?php
declare(strict_types=1);
// Assume libgeom.so exports:
// typedef struct { double x; double y; } Point;
// double distance(const Point *a, const Point *b);
// void translate(Point *p, double dx, double dy); // mutates in place via pointer
$ffi = FFI::cdef(
'typedef struct { double x; double y; } Point;' .
'double distance(const Point *a, const Point *b);' .
'void translate(Point *p, double dx, double dy);',
'/usr/lib/libgeom.so',
);
$origin = $ffi->new('Point');
$origin->x = 0.0;
$origin->y = 0.0;
$point = $ffi->new('Point');
$point->x = 3.0;
$point->y = 4.0;
echo $ffi->distance(FFI::addr($origin), FFI::addr($point)) . "\n"; // 5.0
// translate() mutates $point in place through the pointer -- the CData object reflects
// the C function's write, since it's the same underlying memory, not a PHP copy.
$ffi->translate(FFI::addr($point), 1.0, 1.0);
echo "{$point->x}, {$point->y}\n"; // 4, 5<?php
declare(strict_types=1);
// Assume libtext.so exports:
// char *make_upper(const char *input); // caller must free() the returned pointer
// void free_string(char *ptr); // library's own matching free helper
$ffi = FFI::cdef(
'char *make_upper(const char *input);' .
'void free_string(char *ptr);',
'/usr/lib/libtext.so',
);
function toUpperNative(FFI $ffi, string $input): string
{
$cPtr = $ffi->make_upper($input); // library allocated this memory -- we now own it
try {
return FFI::string($cPtr); // copy the C string into a real PHP string
} finally {
$ffi->free_string($cPtr); // MUST free using the library's own matching free function,
// never PHP's own memory management, and never twice
}
}
echo toUpperNative($ffi, 'hello world') . "\n"; // HELLO WORLDFFI Call Path: PHP to Native Machine Code
Every layer must agree exactly -- a mismatched cdef declaration doesn't always error cleanly, it can silently misread the native memory layout.
PHP
|
v
FFI (marshals PHP <-> C types per your cdef declarations)
|
v
Native C ABI (calling convention, struct layout, integer sizes)
|
v
Shared Library (.so / .dll / .dylib, already compiled, knows nothing about PHP)
|
v
Native Function (raw machine code)Important Rules
Wrap FFI usage behind a small, narrow, well-tested internal PHP API -- never scatter raw FFI calls through general application code.
Know and document the memory-ownership contract of every FFI-called function before using it: who allocates, who frees, with what.
Never let an uncaught PHP exception unwind across a C callback boundary -- catch inside the callback itself.
Match cdef declarations exactly to the target library's real ABI (struct layout, integer sizes, calling convention) for the target platform.
Keep FFI code away from directly processing untrusted/user-controlled input; a bug here can mean memory corruption, not just a caught exception.
Treat FFI as an advanced, narrowly-scoped tool -- prefer an existing PHP extension or pure-PHP solution when one reasonably exists.
Why This Matters in Production
FFI is genuinely useful for a narrow set of production cases -- calling a vetted, high-performance C library for image processing, numerical computation, or hardware access that has no good pure-PHP equivalent -- but it's also the single easiest way for PHP code to introduce a native-memory-corruption vulnerability, a bug class PHP's managed memory model normally protects against entirely. Teams that adopt FFI generally quarantine it: a small, heavily reviewed internal package, disabled outside a controlled preload context, never touched by request-handling code that processes raw user input directly, and treated in code review with the same scrutiny as C code itself -- because at the FFI boundary, it effectively is C code.
$cPtr = $ffi->make_upper($input); return FFI::string($cPtr); // return without ever freeing $cPtr
$cPtr = $ffi->make_upper($input); try { return FFI::string($cPtr); } finally { $ffi->free_string($cPtr); }
The C library documented make_upper() as returning caller-owned, malloc'd memory. Never freeing it is a memory leak that PHP's own garbage collector cannot see or reclaim, because that memory isn't managed by PHP at all.
// cdef declares: struct Point { int x; int y; } -- but the real library compiled Point's fields as double x, y;
// cdef must exactly match the library's real, compiled struct layout: struct Point { double x; double y; }
A mismatched struct declaration doesn't necessarily crash -- it can silently misinterpret the bytes at each field's offset, reading garbage or wrong values without any PHP-level error, which is far harder to debug than an outright crash.
[multiple-choice] What is the core difference between using FFI and writing a traditional PHP extension? A. FFI is only usable for pure-PHP code, never for calling C B. FFI calls an already-compiled native library at runtime using declarations as metadata, with no PHP-side compilation step, whereas a PHP extension is C code compiled specifically against PHP's internal APIs C. There is no real difference -- FFI is just a PHP extension itself D. FFI only works with libraries written specifically for PHP
B. FFI calls an already-compiled native library at runtime using declarations as metadata, with no PHP-side compilation step, whereas a PHP extension is C code compiled specifically against PHP's internal APIs — FFI lets PHP call into existing, already-compiled native libraries that know nothing about PHP, using C declarations purely as metadata for correct calling; a PHP extension, by contrast, is compiled directly against PHP's own internal C APIs.
[multiple-choice] Why can't FFI automatically determine whether PHP code should free memory returned by a C function? A. FFI always frees returned memory automatically, so this is never a concern B. Memory ownership is defined by each library's own documented contract (who allocates, who's expected to free, and with what function) -- FFI has no way to infer that from the pointer alone C. PHP's garbage collector automatically tracks and frees all FFI-returned memory D. C functions never return pointers that require freeing
B. Memory ownership is defined by each library's own documented contract (who allocates, who's expected to free, and with what function) -- FFI has no way to infer that from the pointer alone — A returned pointer could be caller-owned (you must free it, often with a specific matching function), library-owned (never free it yourself), or static memory -- which one applies is a fact about that specific library's contract, not something inferable from the pointer's type or value.
[multiple-choice] Why should FFI usage typically be kept far away from code that directly processes untrusted user input? A. FFI calls are always too slow for user-facing requests B. FFI bypasses PHP's usual memory and type safety, so a bug reachable through it can escalate to native memory corruption rather than a caught PHP exception C. PHP disallows FFI from being called during an HTTP request entirely D. User input can never be validly passed to a C function under any circumstances
B. FFI bypasses PHP's usual memory and type safety, so a bug reachable through it can escalate to native memory corruption rather than a caught PHP exception — Because FFI operates with raw C-level memory access and no bounds checking, a bug that would be a caught exception in pure PHP (like an out-of-range index) can instead become memory corruption or a crash when it happens on the FFI side -- a categorically more dangerous failure mode, especially when triggered by attacker-controlled input.
You're asked to wrap a C library function int compress(const char *input, size_t input_len, char *output, size_t output_cap) that writes compressed bytes into a caller-provided output buffer and returns the number of bytes written, or -1 on failure. Describe (in words or pseudocode) how you'd allocate the output buffer from PHP, call the function safely, and avoid both a buffer overflow and a memory leak.
Allocate the output buffer yourself from PHP with a generous fixed capacity, e.g. $buf = FFI::new('char[' . $cap . ']'), so no C-side malloc/free is involved and PHP/FFI manages that memory's lifetime automatically. Call compress($input, strlen($input), $buf, $cap) and check the return value: if it's -1, treat it as failure (buffer too small or other error) rather than reading $buf; if it's >= 0, use FFI::string($buf, $returnedLength) to copy exactly that many bytes into a PHP string, never assuming the buffer is null-terminated or reading past $returnedLength.
Think about who allocates the output buffer (you, from PHP, via $ffi->new('char[N]')) versus who allocates output for make_upper() in the earlier example -- ownership direction changes the correct pattern.