22 KiB
TypePHP Scope Management Design
This document is an internal implementation document for TypePHP and PHPX. It explains the responsibilities, implementation, lifecycle, performance characteristics, and applicable scenarios of the three current scope managers. The "scope" here is not a single Zend concept: callable resolution, execution-frame class scope, and EG(fake_scope) each serve different subsystems and cannot be substituted for one another.
1. Design Goals
The C++ methods generated by TypePHP are not ordinary Zend user functions. When a dynamic call returns to the ZendVM, Zend still needs the following information to reproduce PHP's visibility rules:
- The lexical scope of the declared method, used to determine whether private/protected members are accessible;
- The called scope of the current late static binding;
- The current instance
$this, used to resolve non-static method callables; - The
EG(fake_scope)read by certain Zend property, object, and exception APIs.
The Scope design follows these principles:
- Prefer passing scope explicitly, and do not modify Zend's global or real execution-frame state.
- Create a reusable callable context at most once per AOT method call; multiple calls within a loop share it.
- Temporarily modify the nearest user-code frame only when the compiler cannot determine the callback location.
- Use RAII when modifying Zend executor state, and guarantee restoration on the exception path.
- Do not pay extra wrapping cost for pure Native Calls or public, absolutely-located callbacks.
2. Overview
| Manager | Managed State | Primary Purpose | Modifies Zend Current State |
|---|---|---|---|
php::CallableScope |
A synthetic zend_execute_data containing lexical scope, called scope, and $this |
Dynamic method calls, first-class callables, built-in function callbacks | No |
php::UserCodeScopeGuard |
The zend_function::common.scope of the nearest user-code frame |
call_user_func* and dynamic call paths where the callback is hidden inside argument unpacking |
Yes, restored on destruction |
php::FakeScopeGuard |
EG(fake_scope) |
Zend property, object, exception, and other APIs that read fake scope | Yes, restored on destruction or explicit restore() |
The selection rule can be simplified as:
- A concrete callable value is available: use
CallableScope. - Calling
call_user_func*, or the callback of another built-in function is hidden in...$args: useUserCodeScopeGuard. - The called Zend API explicitly reads
EG(fake_scope): useFakeScopeGuard. - Pure native calls or operations that do not depend on caller visibility: do not create any Scope manager.
3. php::CallableScope
3.1 Responsibilities
CallableScope is the main path for ordinary callable resolution today. It explicitly hands the caller context to zend_is_callable_at_frame() to:
- Resolve private/protected methods;
- Resolve
self,parent, andstaticcallbacks; - Preserve the called scope of late static binding;
- Provide the real
$thisfor non-static methods; - Invoke dynamic methods without modifying
EG(current_execute_data)or the real execution frame.
It does not handle property access and does not set EG(fake_scope).
3.2 Internal Structure
The class is defined in PHPX's include/phpx.h and holds:
zend_function *caller_function_;
zend_class_entry *called_scope_;
zend_object *this_object_;
mutable zend_execute_data frame_{};
On construction, it initializes a synthetic frame through zend_vm_init_call_frame():
caller_function_->common.scopeis the lexical scope, i.e. the class that declares the current method;called_scope_is the runtime called scope;- Instance calls set
ZEND_CALL_HAS_THISand carry the realzend_object *; - Static calls do not carry an object and only pass the called scope;
- If the called scope is empty, it falls back to the lexical scope.
Resolution calls:
zend_is_callable_at_frame(callable, object, &frame_, 0, cache, error);
The synthetic frame is not installed into EG(current_execute_data), so it does not pollute the current Zend call stack, and global state does not need to be restored on exit.
3.3 Lifecycle and Ownership
CallableScope does not own zend_function, zend_class_entry, or zend_object; it only borrows these pointers within the current AOT method stack frame:
- TypePHP-compiled methods use persistent
zend_function, whose lifecycle spans the request invocation; - A Closure's
zend_function *is valid for the lifetime of the Closure object; $thisis valid during execution of the current method;CallableScopeis non-copyable and non-movable, preventing the synthetic frame from being accidentally transferred or stored across lifecycles.
CallableScope must not be cached beyond the request, nor be allowed to outlive its owning method or Closure.
3.4 Compiler Generation Pattern
The compiler lazily requests a Scope variable through FunctionContext::$callableScopeVar. The first time an explicit callable scope is needed, getCallableScopeExpr() allocates a temporary variable; subsequently genScopeVarDecl() hoists the initialization code to the function entry:
php::CallableScope tmp_var_1 = php::getCallableScope(
get_persistent_method(...),
this_
);
php::getCallableScope() builds both the called scope and the real instance information from this_. All scoped calls within a method reference the same tmp_var_1, so repeated calls within a loop do not recreate the synthetic frame.
If a method never uses scoped dynamic calls, first-class callables, or scoped callbacks, the compiler does not generate this variable.
3.5 Usage Entry Points
php::callScoped()
Used for dynamic function or object method calls. Internally, call_function_impl() uses CallableScope::resolve() to obtain a zend_fcall_info_cache, then executes zend_call_function().
The typical scenario is when the compiler cannot resolve an object method into a Native Call, but still needs to preserve access to the current class's private/protected members.
php::makeScopedCallable()
Used for first-class callable syntax. The result of this syntax must be a real Closure, so even if the target method is public, it cannot simply return the original callback array or string.
$callback = self::privateMethod(...);
$callback = $this->publicMethod(...);
Ordinary methods create a Closure through zend_create_fake_closure(). If Zend returns ZEND_ACC_CALL_VIA_TRAMPOLINE, a forwarding Closure is used to preserve the dynamic semantics of magic __call() / __callStatic().
php::prepareScopedCallback()
Used to pass a callback to PHP built-in functions such as array_map() and usort(). The goal here is only for the built-in function to invoke the callback correctly; it is not required that the argument itself become a Closure.
Therefore, it first reuses the original value of the following callbacks:
- Public methods;
- Located by absolute class name;
- Not relying on a trampoline.
Only private/protected methods, self / parent / static relative callbacks, or trampolines create a Closure. This avoids unconditionally allocating a fake Closure each time a built-in function is called within a loop.
3.6 Why self / parent / static Still Need Runtime Recognition
In direct syntax, self::class can be expanded to a concrete class name at compile time, but PHP callbacks also allow dynamic values:
$class = 'self';
$callback = [$class, 'method'];
In this case, only at runtime can we know whether the class name in the array is a relative class name. Therefore isRelativeCallableClass() cannot be fully moved to compile time. For known absolute public callbacks, this check returns false quickly and reuses the original value.
4. php::UserCodeScopeGuard
4.1 Responsibilities and Scope of Application
UserCodeScopeGuard serves fully dynamic call_user_func() / call_user_func_array(), callback maps, and argument-unpacking scenarios where the compiler cannot statically rewrite the callback.
$args = [[$this, 'privateMethod'], 1];
call_user_func(...$args);
A built-in function callback may be at a fixed position, a reverse position, in a named argument, or even a single function may have multiple callbacks. Before executing the ...$args unpacking, the compiler does not know the final positional/named argument layout and cannot call prepareScopedCallback() only on the corresponding values.
call_user_func* itself is a fully dynamic call boundary of the ZendVM; regardless of whether the callback appears explicitly, no fake Closure is created. If the callable array uses self, parent, or static, normalizeCallableClass() first converts the class part into a real class name:
selfbecomesCallableScope::lexicalScope();parentbecomes the parent class of the lexical scope;staticbecomesCallableScope::calledScope().
Normalization only copies the callback arrays that need modification. Absolute class names, object callbacks, Closures, and ordinary function names keep their original values.
preg_replace_callback_array() is a special case of a callback map. Zend resolves callbacks in the map item by item internally; if the map were wrapped in advance, each call would perform an O(N) scan and might trigger array COW and multiple Closure allocations. Therefore the compiler keeps the original map and creates a UserCodeScopeGuard once at the method entry, letting Zend resolve directly with the correct scope.
Apart from fully dynamic calls, callback maps, and argument unpacking, ordinary callback arguments must not use this guard; as long as the AST parameter position of a single callback is known, the CallableScope path should be used.
4.2 Implementation
The constructor walks upward from EG(current_execute_data) to find the nearest user-code frame, skipping internal frames:
while (frame && (!frame->func || !ZEND_USER_CODE(frame->func->type))) {
frame = frame->prev_execute_data;
}
Once found, it saves it and uses CallableScope::lexicalScope() to set the visibility scope:
function_ = frame->func;
previous_scope_ = function_->common.scope;
function_->common.scope = callable_scope.lexicalScope();
The destructor restores previous_scope_. The class is non-copyable and non-movable, ensuring one construction corresponds to one restoration. If no user-code frame is available, it throws:
A user-code frame is required for scoped dynamic callback calls
This guard operates on the user-code frame found from the current request's execution chain, not on the persistent internal methods registered by TypePHP in MINIT. EG(current_execute_data) itself belongs to the current executor context. Its impact window is limited to the RAII lifecycle of the current AOT method call.
4.3 Compiler Generation Pattern
The compiler maintains a semantically clear flag:
FunctionContext::$needsUserCodeCallableScope
When the compiler encounters a dynamic callback of call_user_func*, or a built-in function known to synchronously invoke callbacks has an argument unpacking that cannot be matched, markUserCodeCallableScope() sets this flag. The state belongs to the current FunctionContext, so ordinary methods, nested Closures, and Fibers are independent and do not leak the guard into an outer function incorrectly. Each function body generates only one at its entry:
php::CallableScope tmp_var_1 = php::getCallableScope(..., this_);
php::UserCodeScopeGuard tmp_var_2{tmp_var_1};
Even if the call form is call_user_func($closure), and inside the Closure there is another call via
call_user_func(['self', 'method']), each layer only reads its own
FunctionContext, lexical scope, and $this, and cannot reuse or pollute the outer guard.
It is not created per call site or per loop iteration. Methods without the above dynamic callbacks incur no such cost.
4.4 Why This Fallback Is Currently Kept
If it were completely removed, the compiler would have to add a structured argument binding and rewriting flow after argument unpacking completes, correctly handling:
- Merging positional and named arguments;
- Forward and reverse positions of callbacks;
- Multiple callbacks in one function;
- Callback maps;
- PHP error semantics when arguments are duplicated, missing, or overridden during unpacking.
This is not a localized replacement, but a medium-scale refactoring of parseCallArgs() and the argument container generation flow. Until a unified runtime argument post-processing mechanism is completed, keeping the strictly controlled UserCodeScopeGuard is simpler and more reliable.
5. php::FakeScopeGuard
5.1 Responsibilities
FakeScopeGuard is the RAII wrapper for EG(fake_scope). Some Zend APIs do not accept an explicit call frame; instead, they directly read EG(fake_scope) to determine class member visibility or perform class-scope-related operations. Only these APIs should use it.
Current typical scenarios include:
- Dynamic property reads, writes, and property hooks;
- Zend object handler calls;
- Default values or object initialization under class scope;
- Zend operations related to exception objects;
- Other Zend internal interfaces that explicitly read
EG(fake_scope).
TypePHP's property access generator passes the current fake scope to the PHPX property helper through FakeScopeGuard::current().
5.2 Implementation
On construction it saves the old value and sets the new value; on destruction it restores it:
explicit FakeScopeGuard(Scope scope) noexcept : previous_(current()) {
EG(fake_scope) = scope;
}
~FakeScopeGuard() noexcept {
restore();
}
Scope is deduced through decltype(EG(fake_scope)) to be compatible with both PHP 8.4's mutable pointer and PHP 8.5's pointer-to-const. restore() is idempotent and can be safely called once in advance.
5.3 Zend Bailout Considerations
C++ exception unwinding executes destructors, but Zend bailout uses longjmp and does not execute C++ destructors. If a guard's lifecycle crosses a bailout boundary, it must be explicitly invoked in the corresponding zend_catch path:
fake_scope_guard.restore();
and then continue the bailout or convert the exception. Relying solely on the destructor to handle bailout is incorrect.
5.4 Non-applicable Scenarios
FakeScopeGuard cannot replace CallableScope:
- It has no synthetic frame;
- It cannot carry
$this; - It cannot fully express lexical scope and called scope;
- The resolution semantics of
zend_is_callable_at_frame()should not be indirectly simulated through a global fake scope.
Likewise, EG(fake_scope) must not be unconditionally set at the entry of every AOT method just because "private access might be needed." This would widen the impact of global state and make unrelated native-intensive calls bear the cost.
6. Call Flows of the Three Scopes
6.1 Known Dynamic Method Call
AOT method entry
-> lazily generated CallableScope
-> php::callScoped()
-> CallableScope::resolve()
-> zend_is_callable_at_frame(synthetic frame)
-> zend_call_function()
The whole process does not modify the real Zend frame.
6.2 Known Built-in Function Callback
compiler marks callback argument
-> prepareScopedCallback(value, CallableScope)
-> public absolute callback: reuse value
-> scoped/trampoline callback: create Closure
-> call PHP internal function
First-class callables use the same resolution basis but must call makeScopedCallable() and return a Closure.
6.3 Callback Inside Argument Unpacking
AOT method entry
-> UserCodeScopeGuard changes nearest user-code frame scope
-> internal function receives expanded arguments
-> Zend resolves hidden callback using that frame scope
-> method exit / C++ exception unwind
-> guard restores original scope
6.4 Property or Object Handler
save EG(fake_scope)
-> install FakeScopeGuard
-> call Zend property/object API
-> restore on normal/C++ exception exit
-> explicitly restore in zend_catch if bailout is possible
7. Forbidden Mixing and Maintenance Constraints
- Do not use
FakeScopeGuardto resolve callables. - Do not modify the real user-code frame for ordinary known callbacks; use
prepareScopedCallback(). - Do not let
UserCodeScopeGuardbecome the general entry point for all dynamic calls again. - Do not recreate
CallableScopeat call sites within loops; it should be hoisted to the method entry byFunctionContextand reused. - Do not cache the function, object, or synthetic frame borrowed by
CallableScopebeyond the request. - Do not change first-class callables to return the original callback; its PHP result type must be Closure.
- When adding a PHP built-in function that synchronously invokes callbacks, update the callback argument description table, noting the position, argument name, and whether it is a callback map.
- Functions that save a callback but do not invoke it immediately must not mark the scope fallback merely because they receive a callable, for example
spl_autoload_register(). - When adding a
FakeScopeGuardusage that crosses a Zend bailout, code review must check whetherzend_catchexplicitly restores it.
8. Performance Model
| Path | Main Cost | Optimization Strategy |
|---|---|---|
CallableScope |
Initializing one synthetic frame | At most once per AOT method, reused across loops |
callScoped() |
Dynamic resolution by zend_is_callable_at_frame() |
Used only for dynamic calls; resolvable Native Calls do not enter this path |
prepareScopedCallback() |
One callable resolution | Public absolute callbacks do not create a Closure |
makeScopedCallable() |
Callable resolution and Closure allocation | Used only for first-class callables |
UserCodeScopeGuard |
One pointer lookup and write at method entry, plus restoration at exit | Generated only for call_user_func*, callback maps, or unresolved unpack callbacks |
FakeScopeGuard |
Two executor-global pointer assignments | Only surrounds Zend APIs that actually read fake scope |
This design deliberately keeps common pure Native Calls, callback-free methods, and public callbacks on the shortest path. Do not sink low-frequency fallbacks into every call just to unify the surface form.
9. Test Requirements
Scope changes should cover at least the following layers:
- PHPX unit tests:
FakeScopeGuardsave, nesting, restoration, and earlyrestore(); - Compiler structure tests: one method generates only one
php::getCallableScope(), with multiple call sites reusing the same variable; - PHPT: private/protected callbacks, non-static
self::method(...), public callbacks; - PHPT: a mix of public and scoped callbacks in a callback map;
- PHPT: a private callback inside
...$argsis callable, and the scope is restored after an exception exit; - PHPT: scope generation paths in Closure, Fiber, and ordinary methods;
- Regression tests: pure Native Calls must not generate extra Scope guards.
Current related tests include:
phpunit/src/ScopedCallContextTest.phpphpunit/code/scoped-call-context-reuse.phptests/compiler/place-holder/non-static-self.phpttests/compiler/callable/scoped-internal-callbacks.phpttests/compiler/callable/unpacked-callback-scope-restored.phpt- PHPX
tests/src/scope_guard.cpp
PHPT tests involving dynamic calls that throw exceptions may trigger known ZendVM memory leak reports; only when the leak is confirmed to come from Zend's dynamic call exception path may the test locally set USE_ZEND_ALLOC=0, and memory checking must not be disabled globally.
10. Code Location Index
| Content | Location |
|---|---|
CallableScope and public helper declarations |
vendor/swoole/phpx/include/phpx.h |
| Callable resolution and wrapping | vendor/swoole/phpx/src/core/base.cc, vendor/swoole/phpx/src/core/closure.cc |
FakeScopeGuard |
vendor/swoole/phpx/include/phpx_fake_scope_guard.h |
UserCodeScopeGuard |
vendor/swoole/phpx/include/typephp_helper.h, src/core/scope.cc |
php::getCallableScope() |
vendor/swoole/phpx/include/typephp_helper.h |
| Callback marking and Scope variable generation | src/CompilerBase.php |
| Callback argument wrapping | src/Generator/CallArgumentGenerator.php |
| Closure/Fiber fallback guard | src/Generator/ClosureGenerator.php, FiberGenerator.php |
| Method fallback guard | src/Translator.php |
| Scope state | src/Context/FunctionContext.php |
| Fake scope in property access | src/Parser/PropertyAccessTrait.php |
11. Future Evolution Principles
UserCodeScopeGuard is a long-term retained mechanism for complex dynamic calls and is not targeted for removal. It modifies the user-code frame in the current thread and current request, and restores it via RAII; under ZTS, different threads have their own execution contexts, so the modified frame state is not shared.
CallableScope is used for the single scenario where the compiler can determine the callback location and call boundary, in order to reduce frame modification and Closure wrapping; it is a faster, more explicit path, not one required to cover all scenarios such as unpacking and multi-layer dynamic callbacks. When encountering combinations that are hard to prove safe statically, prefer keeping UserCodeScopeGuard, and do not forcibly rewrite it to CallableScope for the sake of formal uniformity.
Before adding a new Scope abstraction in the future, first confirm whether the Zend API depends on a synthetic call frame, a real user-code frame, or EG(fake_scope). The name and type should directly express the managed Zend state, avoiding the reappearance of an overly broad general-purpose Scope class.