16 KiB
TypePHP's Three Object Storage and Passing Models
Status: current architectural constraint. This document explains why TypePHP simultaneously keeps three object-style value models — Zend Object, PHPX Box, and Native Class Object — along with their respective ownership, passing methods, and boundaries.
1. Conclusion
TypePHP currently has three object storage and passing mechanisms:
- Ordinary PHP/Zend Objects;
- PHPX Box, including Std Containers and high-precision types;
#[Native]Native Class Objects.
These three are not historical residue of the same design, but separately solve three mutually conflicting problems:
- Zend Object preserves PHP's dynamic object semantics and ZendVM ecosystem compatibility;
- Box provides an opaque Zend value carrier for C++ types that cannot be fully written into PHP type declarations;
- Native Class Object provides statically-knowable business objects with a fixed layout close to C/C++, raw-pointer calls, and tracing GC.
None of these mechanisms can replace the other two without losing a core capability of the others. The current design explicitly accepts the long-term coexistence of the three models and does not target a "unified object representation".
2. Overview
| Dimension | Zend Object | PHPX Box | Native Class Object |
|---|---|---|---|
| Typical value | Ordinary PHP class instance | Std Container, BigInt, BigFloat, Decimal | #[Native] class instance |
| Primary representation | zend_object / zval |
zend_resource + php::Box * |
C++ struct in Native Heap + raw pointer |
| Type identity | zend_class_entry * |
Box C++ dynamic type, type_info/type ID |
Compile-time Native class, dynamic type saved in descriptor |
| Lifecycle | Zend reference counting + Zend cycle GC | Zend resource reference counting calling the Box destructor | Wren-style precise, non-moving mark-sweep GC |
| Argument passing | php::Object / php::Var, copying the handle and adjusting RC |
php::Var carrying the resource; hot paths extract the concrete C++ reference |
Concrete NativeClass * passed by value, without adjusting RC |
| Property/method access | Zend handlers, dynamic lookup, or already-cached Native Call | The compiler generates operations based on the concrete Box type | Fixed-offset field access and definite php_* Native Call |
| Dynamic PHP interop | Complete | Limited interop as an opaque resource | Cannot enter the ZendVM value boundary |
| Cyclic graph handling | Zend GC can scan the Zend object graph | Zend GC does not scan the C++ object graph inside Box | The Native descriptor precisely traces the Native pointer graph |
| Core goal | PHP compatibility | Carrying C++ generic/extended values | Extreme static performance |
3. Ordinary PHP/Zend Object
3.1 Storage
Ordinary classes are registered with the ZendVM, and instances are represented by zend_object. TypePHP holds the corresponding zval through PHPX RAII types such as php::Object,
php::Variant/php::Var.
The object has Zend's class entry, property table, object handlers, and method metadata. Based on compile-time information, TypePHP can optimize some accesses into definite Native Calls, but the object identity and lifecycle still belong to the ZendVM.
3.2 Passing and Lifecycle
PHP object assignment and argument passing copy the object handle, not the object entity, and follow Zend reference counting. Cyclic references in the object graph are handled by Zend GC. Objects can naturally enter:
- PHP arrays and ordinary object properties;
mixed/objectvariables;- Closures, Generators, Fibers, and dynamic calls;
- Reflection, serialization, and extension functions;
- PHP code executed by the ZendVM.
3.3 Why It Must Be Kept
Only Zend Object can fully carry PHP's runtime object semantics. Replacing it with Box would lose the class entry, object handlers, visibility, Reflection, and dynamic dispatch; replacing it with Native Object would lose ZendVM visibility, and force all dynamic behavior to degrade to compile-time restrictions.
Ordinary PHP classes therefore always use Zend Object. The compiler can optimize calls, but cannot change its object model.
4. PHPX Box
4.1 Storage
php::Box is a C++ polymorphic base class managed by PHPX. The Box pointer is registered as a Zend resource and carried by
php::Var:
zval(IS_RESOURCE)
-> zend_resource
-> php::Box*
-> concrete C++ value
The Zend resource's destructor callback ultimately calls Box::destroy(). Box can therefore pass through ordinary zval/Variant
call boundaries while hiding the concrete C++ type that Zend cannot express.
Current main users include:
StdContainerBox<std::vector<T>>;StdContainerBox<std::array<T, N>>;StdContainerBox<map-like type>;- High-precision values such as BigInt, BigFloat, and Decimal.
4.2 The Std Container Hot Path
Std Container local variables have a two-layer representation:
php::Var values = php::Var(new php::StdContainerBox<Container>(type_id));
auto &values_ref = values.toBox<php::StdContainerBox<Container>>()->container;
php::Var is responsible for the lifecycle and necessary boundary passing; the concrete container reference is used for subsequent element access, avoiding re-extracting the Box on every operation. The container's key/value/length and other generic information are jointly saved by the compiler and the concrete C++ template type.
When a Std Container is passed across TypePHP functions, the PHP function signature cannot express the following C++ type information:
std::vector<int>
std::vector<string>
std::map<string, App\User>
A PHP parameter can at most declare a non-generic class name or pseudo-type; it cannot simultaneously carry the container kind, key type, value
type, array dimensions, and length. The current approach uses UnsafePtr/std::unsafe_cast() with compiler type ID checking,
rather than generating every combination as a PHP class.
In theory, parameter and return value annotations could be added to describe generics, but this would require maintaining extra metadata at every declaration, call, return, property, and propagation point, and PHP Reflection still cannot fully express it. This standalone generic ABI is not being introduced for now.
4.3 Box Boundaries
Box is an opaque value carrier, not a general-purpose object system:
- Zend GC only sees the resource and does not scan C++ references held inside Box;
- Box does not provide PHP class method tables, property tables, inheritance, or Reflection;
- The concrete type is recovered through
dynamic_cast, type ID, or dedicated helpers; - Box should not be used to build arbitrary cyclic object graphs that require bidirectional Zend/Box tracing;
- The usable locations and escape paths of Std Container continue to be restricted by the compiler.
Box is suitable for numeric values, containers, and other extension values with clear boundaries. It is not suitable for replacing Native business objects with arbitrary field reference relationships.
4.4 Why It Must Be Kept
The generic types of Std Container cannot be fully expressed by PHP function parameters; high-precision values in turn need to participate in existing operations and calls as
php::Var. Box provides all of the following:
- A stable carrier that can be placed into a zval;
- Runtime recovery of the concrete C++ type;
- Automatic destruction within the Zend request lifecycle;
- A lightweight implementation that does not register a PHP class for each template instantiation.
Zend Object cannot directly express C++ template instances; Native raw pointers cannot safely cross php::Var and dynamic
ZendVM boundaries. Therefore Box still has a reason to exist independently.
5. Native Class Object
5.1 Storage
#[Native] classes do not register a Zend class, do not generate Zend object handlers, and have no zval representation. Each
object is a fixed-layout C++ struct in the Native Heap; TypePHP local variables, parameters, return values, and fields hold
concrete Native pointers:
php_app__point *point;
Methods continue to use TypePHP's free-function ABI:
php::Float php_app__point__length(php_app__point &this_);
Ordinary calls only pass a pointer value. No zval is created, no resource is registered, no reference counting is performed, and nothing goes through
zend_call_function().
5.2 Lifecycle
Native Objects use an independent Wren-style precise, non-moving, stop-the-world mark-sweep GC in PHPX:
- Native local variables, parameters, return temporaries, and global/static slots enter a precise root frame;
- The Native object descriptor is responsible for tracing Native pointer fields;
- When a Std Container saves a Native pointer, a dedicated container root frame is registered;
- Cyclic references are collected by the tracing GC, without relying on reference counts dropping to zero;
- The 16-byte GC header saves the minimal state required by the collector;
__destruct()is executed by Native finalization, not by the Zend object destructor.
Native pointer assignment does not increase the reference count and does not need a write barrier. Fixed fields are accessed directly by C++ offset.
5.3 Passing Boundaries
Native Object parameters and return values must explicitly declare a concrete Native class, or a supported nullable concrete type:
function distance(Point $left, Point $right): float;
function findPoint(): ?Point;
This lets the compiler generate the signature directly as Point *. Native Objects do not support:
- Passing to PHP/ZendVM functions, Closures, or dynamic callables;
- Saving into PHP arrays, ordinary Zend Object properties, or
mixed; - Automatic conversion to
php::Object,php::Var, or Interface value; - Recovering the type through the runtime class name;
- Using the generic PHPX
toObject()helper to complete boxing or unboxing. Native Classes can declare their owntoObject(): objectmethod; keyword calls resolve directly to that Native Call and do not provide a generic bridge.
When entering the PHP API, the user must explicitly convert the data, for example first calling Native toArray(): array, then passing
the result to json_encode(). This conversion produces a data copy and does not preserve the Native object identity.
5.4 Why It Must Be Kept
The goal of Native Class is hot-path performance close to C/C++:
- A one-machine-word object handle;
- Fixed field layout;
- No Zend RC increment/decrement;
- No
zend_objectorzend_resourcecarrier allocation; - Definite-symbol Native Calls;
- Inlinable and devirtualizable by the C++ compiler.
If Box were used instead, each Native Object would need resource/zval wrapping, RC management, and concrete type recovery, and
Zend GC cannot scan the Native pointer graph inside Box; this both reduces performance and cannot correctly replace Native tracing
GC. If a custom zend_object were used instead, although it could connect to Zend GC and dynamic boundaries, the object header, RC,
handlers, and access paths would all change the performance positioning of Native Class.
Therefore Native Class continues to use an independent Native Heap and a raw-pointer ABI.
6. Why They Cannot Be Unified
6.1 They Cannot All Become Zend Object
This would unify dynamic semantics, but it would make Std Container generic instances and Native Class both bear the Zend object header, RC, handlers, class registration, and dynamic access costs. Native Class would no longer be close to C/C++, and Std Container would need a runtime class system designed for a large number of template combinations.
6.2 They Cannot All Become Box
Box can carry C++ values through zval, but Zend GC does not understand the object graph inside Box. It cannot replace the dynamic metadata of ordinary PHP Object, nor can it provide a raw-pointer hot path while retaining Native cycle collection capability.
6.3 They Cannot All Become Native Pointers
Native pointers require complete static typing. Ordinary PHP objects need Reflection, dynamic properties, dynamic callables, and Zend extension interop; the complete generic types of Std Container cannot be written into PHP parameter signatures. Turning these values into raw pointers would produce type erasure that cannot be proven safe statically, and could lead to incorrect pointer conversion and crashes.
6.4 No Automatic Bridging
There is no implicit object identity conversion among the three models. Automatic boxing/unboxing would hide allocation, copying, RC, and GC root changes, and would also make compiler boundaries no longer reliable.
Allowed conversions must have clear semantics:
- Std Container to PHP array: copies container data;
- Entity methods of Native Object such as
toArray(): defined by the user and explicitly copy data; - Explicit scalar conversion of high-precision types: produces new PHP scalar values;
- Ordinary Zend Object does not automatically become a Native Object.
7. Compiler Implementation Constraints
Future changes must preserve the following invariants:
- Determine the object model from the static type first, then choose the code generation path; never guess one of the three at runtime.
- Native Objects must not be wrapped into
php::Varor passed into the ZendVM due to a generic fallback. - Box concrete type recovery must validate the resource type and the concrete C++ type / type ID.
- Zend Object optimization must not change Zend object identity, lifecycle, or dynamic visibility.
- The argument ABIs of the three models must not be mixed:
php::Object, Box-bearingphp::Var, andNativeClass *respectively represent different ownership and type constraints. - Cross-model conversions must be explicit, and the allocation or copying cost must be reflected in documentation and generated code.
- If a new feature requires sacrificing all Native Class hot paths to gain a small amount of dynamic compatibility, it should be prohibited at compile time first.
- If a new C++ generic type needs to cross the Zend value boundary, Box should be evaluated first, rather than widening the dynamic boundary of Native Object.
- If a value needs complete PHP object semantics, Zend Object should be used, and Box must not be treated as a simplified PHP class.
8. Code Locations
Main implementation entry points:
Ordinary Zend Object
compiler/src/Parser/*
phpx/include/phpx.h Object / Variant / Zend API wrappers
PHPX Box and Std Container
phpx/include/phpx.h Box / StdContainerBox<T>
phpx/src/core/base.cc Box resource registration and destructor
compiler/src/Parser/StdContainerTrait.php
Native Class Object
compiler/src/NativeClass/
compiler/src/Transform/NativeClassAttributeLowering.php
phpx/include/phpx_native_gc.h
phpx/src/core/native_gc.cc
phpx/thirdparty/wren-gc/
Detailed rules are in STD_CONTAINERS.md, NATIVE_CLASS_OBJECT.md, and NATIVE_CLASS_IMPLEMENTATION_AUDIT.md.
9. Current Decision
The following refactorings are not being implemented at the current stage:
- Not removing Wren GC;
- Not changing Native Object to Box or a custom Zend Object;
- Not adding a generic
toObject()dynamic recovery mechanism to Native Object; the Native Class customtoObject(): objectremains an ordinary definite Native Call; - Not changing Std Container to a raw-pointer ABI whose type cannot be expressed across signatures;
- Not attempting to cover the three object models with a single unified wrapper.
These boundaries will be re-evaluated in the future only when the PHP language layer can stably express generic parameters, or when a new ABI that has passed benchmark and complete GC correctness validation emerges. Until then, the coexistence of the three mechanisms is an intentional architectural choice.