[PHP-DEV] [RFC] [FFI] Opt-in mapping of C struct types to userland PHP classes

Hi internals,

I’d like to check interest in an FFI improvement before writing a formal RFC. I’ve filed the full proposal with an implementation sketch as a feature request:

https://github.com/php/php-src/issues/23229

The problem: every C value FFI produces - a zend_string*, a zval*, a char* - is one and the same final class, FFI\CData. That single opaque type is what makes FFI flexible, but it also means no C struct a binding works with can ever be described to static analysis or an IDE,
and CData being final closes off every userland workaround.

Today a binding that wants any static typing has to ship a code generator emitting analysis-only stub classes, a .phpstorm.meta.php map, and an analyser extension - and the result is still strictly weaker than the real thing: instanceof can never work and native parameter types can never be enforced. I maintain the lisachenko/z-engine framework, which drives the Zend Engine’s own structs through FFI, and I ship exactly that four-part workaround; every FFI binding generator hits the same wall.

The proposal: an opt-in, per-scope class map, configured through an options array in the spirit of SoapServer/SoapClient:

$ffi = FFI::cdef($code, $lib, options: [
‘classmap’ => [
‘zend_string’ => \My\Engine\ZendString::class,
],
‘typemap’ => [ /* C type => marshalling callbacks */ ],
]);

final class ZendString extends \FFI\CData
{
public int $len { get => …; } // property hooks over raw fields
}

With a type registered, every handle ext/ffi mints for it - from FFI::new(), FFI::cast(), struct-field reads, function returns - is an instance of the mapped class instead of bare CData. get_class() is truthful, instanceof works, and native parameter/return declarations are enforced by the engine.

Implementation-wise this stays local to ext/ffi and is zero-overhead when unused: the object storage remains zend_ffi_cdata with shared handlers, only the ce pointer differs, so GC, clone, field access and lifetime behave byte-for-byte as today. The only relaxation is that FFI\CData becomes extendable for registered classes. Fully opt-in, no BC impact.

I’m targeting PHP 8.6 ahead of feature freeze and volunteering to write the implementation PR - z-engine already emulates these exact semantics in userland, so there’s a strong real-world test bed for it.

The main open design point I’d like feedback on is the typemap callback contract (from_cdata/to_cdata, and when they fire) versus shipping classmap alone in the first iteration.

If the feedback here is positive, I’ll write this up as a formal RFC on the wiki.

Regards,
Alexander Lisachenko