VPHP Ownership And Lifetime
May 18, 2026 · View on GitHub
This document defines the intended ownership boundary for Zend values in VPHP.
Goal
Keep Zend zval lifetime inside the smallest possible bridge boundary.
Application code should primarily work with semantic wrappers.
Preferred public naming:
RequestBorrowedZBoxRequestOwnedZBoxPersistentOwnedZBox
Core Utility: .clone() (formerly clone_persistent_owned) is the primary way to move data from Request scope to Persistent scope.
Implementation model:
RequestBorrowedZBox: borrowed read-only view of an existing Zend valueRequestOwnedZBox: short-lived request-owned valuePersistentOwnedZBox: long-lived detached data or retained handleRetainedObject: long-lived PHP object handleDynValue: detached data-oriented representation
For new code, prefer the short constructor-style entry points:
RequestBorrowedZBox.of(z)RequestOwnedZBox.of(z)PersistentOwnedZBox.of(z)PersistentOwnedZBox.of_mixed(z)PersistentOwnedZBox.of_data(value)PersistentOwnedZBox.try_of_detached(z)
Equivalent top-level helpers are also available when they read better at the call site:
borrow_zbox(z)own_request_zbox(z)own_persistent_zbox(z)
PersistentOwnedZBox.of(z) is the friendly smart-dispatch entry point:
- safe detached data ->
dyn_data - PHP object -> retained object routing
- PHP callable -> retained callable routing
- everything else -> fallback compatibility storage
When the input kind is already known, prefer the more explicit constructors:
of_data(...)of_object(...)of_callable(...)of_mixed(...)
fallback_zval is an internal compatibility fallback, not a recommended storage
model for new application code.
For PHP userland chainable methods that return the receiver itself, make that
intent explicit with @[php_borrowed_return]. A return &self / receiver-alias
instance method should be treated as a borrowed object alias, not as a fresh
owned object result.
Value Wrapper Layers
VPHP separates a PHP value into four layers:
flowchart LR
A["Zend value<br/>C.zval / zend_object / zend_array"] --> B["ZVal<br/>low-level handle"]
B --> C["ZBox<br/>lifetime and ownership"]
C --> D["PHP semantic wrapper<br/>PhpValue / PhpInt / PhpString / PhpObject"]
| Layer | Types | Main question |
|---|---|---|
| Zend value | C.zval, zend_object, zend_array | What does the PHP engine store? |
ZVal | vphp.ZVal | How do we touch the Zend value from V? |
ZBox | RequestBorrowedZBox, RequestOwnedZBox, PersistentOwnedZBox | How long may it live, and who releases it? |
| Semantic wrapper | PhpValue, PhpInt, PhpString, PhpArray, PhpObject | What PHP type does application code mean? |
These layers are intentionally separate:
ZValis the bridge-level handle.ZBoxis the lifetime boundary.PhpValue/PhpInt/PhpString/PhpObjectare the application-facing PHP type vocabulary.
Examples:
// Read an argument as a semantic PHP string. The value is request-borrowed.
fn hello(name vphp.PhpString) string {
return 'Hello ${name.value()}'
}
// Copy a scalar result out of a PHP function call.
len := vphp.PhpFunction.named('strlen').call[vphp.PhpInt](vphp.PhpString.of('codex'))!
// Borrow a complex result only inside the callback.
summary := vphp.PhpFunction.named('array_filter').with_result[vphp.PhpArray, string](fn (filtered vphp.PhpArray) string {
return 'count=${filtered.count()}'
}, items)!
// Store a long-lived value by upgrading the semantic wrapper lifecycle, not by
// keeping a request-borrowed wrapper.
mut result := vphp.PhpFunction.named('factory').invoke()
defer {
result.release()
}
mut stored := result.retain()
Two Independent Axes
These terms answer two different questions:
- How long may this value live?
- Who is responsible for releasing it?
Do not mix them together.
Lifetime: request vs persistent
request
- Valid only for the current PHP request / current bridge call flow
- Typical sources:
ZVal.new_*(), PHP method return values, temporary arg arrays,include()results, temporary wrappers created for a callback - Must not be stored directly in long-lived structs
persistent
- Intended to outlive the current request or be stored in long-lived app state
- Typical destinations: app/container fields, registries, cached definitions, service graphs, route metadata
- Must use a safe long-lived representation instead of blindly storing raw
request
zvalstate
Ownership: borrowed vs owned
borrowed
- Read/use only
- You do not release it
- Good for function parameters, inspection helpers, and short-lived views
owned
- This scope is now responsible for the wrapper / temporary value
- You must either
release()it exactly once, or explicitly transfer it into another owner
Mental Model
Think of the four combinations like this:
borrowed + request
- Temporary read-only view into a current request value
- Typical wrapper:
RequestBorrowedZBox
owned + request
- A temporary value created or returned during the current request
- Typical wrapper:
RequestOwnedZBox - The current scope must release it or transfer ownership
owned + persistent
- A long-lived value stored beyond the current request
- Typical wrappers:
PersistentOwnedZBox,RetainedObject
borrowed + persistent
- Usually not stored directly as a standalone type
- More often appears as a temporary borrowed view over a persistent holder,
such as
with_request_zval(...)
Rules
ZVal.new_*()is request-scoped.
ZVal.new_null(), ZVal.new_string(), ZVal.new_bool(), ZVal.new_int(), and
similar constructors produce request-owned temporary Zend values. They must not
be stored directly in long-lived structs.
PersistentOwnedZBoxis not a generic “store any zval forever” box.
Use it for:
- detached scalar data
- detached string data
- detached dynamic payloads
- retained object handles
Do not treat it as a raw persistent copy of arbitrary request-time
object/closure values.
- The caller owns PHP call results.
Helpers that inspect return values must not also release them. The call site
that receives a ZVal result is responsible for exactly one release().
- Long-lived object/callable state must use dedicated handles.
- PHP objects:
RetainedObject - PHP callables:
RetainedCallablerouted through persistentDynValueviaPersistentOwnedZBox.from_callable_zval(...)/of_callable(...)
- Automatic Root Safety for @[php_class]
V objects exported to PHP (V-backed objects) are automatically registered as GC roots when they enter the PHP-owned object registry.
- V-to-PHP: If you return a V pointer to PHP with
.owned_requestor.owned_persistentownership, the bridge keeps it alive in the V heap. - Low Mental Effort: You do not need to manually manage roots for objects passed to PHP; the bridge handles
GC_add_rootsandGC_remove_rootsunder the hood via a global root registry.
- Debug/logging must not create ownership side effects.
Do not call APIs like to_zval() inside debug interpolation when they allocate
temporary request values.
When To Use What
Choose in this order:
- Will the value be stored beyond the current request/call scope?
- Is this function only reading it, or is it taking responsibility for it?
- If it is long-lived, is it pure data, an object, or a callable?
If the value does not escape the current scope
- Prefer
RequestBorrowedZBoxfor read-only access - Use
RequestOwnedZBoxfor temporary results you own - Keep creation, use, and release inside one small scope
If the value must be stored
- General long-lived input when the type is not known yet:
PersistentOwnedZBox.of(...) - Pure data:
PersistentOwnedZBox.new_*(),of_data(...),try_of_detached(...),of_mixed(...) - PHP object:
PhpObject.retain()when you want to keep object semantics, orPersistentOwnedZBox.from_object_zval(...)/of_object(...)at lower-level storage boundaries - PHP callable:
PhpCallable.retain()when you want to keep callable semantics, orPersistentOwnedZBox.from_callable_zval(...)/PersistentOwnedZBox.of_callable(...)at lower-level storage boundaries
Quick Decision Table
| Situation | Preferred wrapper |
|---|---|
| Read an argument without keeping it | RequestBorrowedZBox.of(...) |
| Call PHP and inspect the result in-place | PhpFunction.with_result(...) / PhpFunction.with_result_zval(...) / PhpCallable.with_result_zval(...) / PhpObject.with_method_result(...) / PhpObject.with_method_result_zval(...) |
| Call PHP and return/hand off the temporary result | RequestOwnedZBox.adopt_zval(...), take_zval() |
| Store a long-lived value when the type is not known in advance | PersistentOwnedZBox.of(...) |
| Store long-lived scalar / string / list / map data | PersistentOwnedZBox.new_*(), of_data(...), try_of_detached(...), of_mixed(...) |
| Store a long-lived PHP object | PhpObject.retain() |
| Store a long-lived PHP callable | PhpCallable.retain() |
In practice:
- prefer
PhpValue,PhpObject,PhpCallable, and the typed semantic wrappers in extension-facing code; drop to*ZBoxonly at lifecycle/storage boundaries - prefer
.retain()as the semantic facade for long-lived storage; useto_persistent_owned()only when you are deliberately working at the lifecycle layer
Practical Rules Of Thumb
- Function parameters should default to semantic wrappers such as
PhpValue,PhpObject,PhpCallable,PhpArray, or scalar wrappers. - PHP call results should default to semantic wrappers such as
PhpValue. - Global PHP function calls should prefer
PhpFunction.call[T](...),PhpFunction.with_result(...), orPhpFunction.invoke(...)over carrying a bareZVal. - PHP object method calls should prefer
PhpObject.call_method(...),PhpObject.method[T](...), orPhpObject.with_method_result(...)over carrying lifecycle boxes through extension code. - Long-lived struct fields should default to persistent wrappers or retained handles.
- The scope that creates an owned request value should also release it, unless it explicitly transfers ownership onward.
Developer's Low Mental Effort Checklist
- Reading Data? Use
RequestBorrowedZBox. Don't bother with release. - Moving Data to Storage? Use
.clone().- Source:
RequestOwnedZBoxorRequestBorrowedZBox - Target:
PersistentOwnedZBox
- Source:
- Clearing Storage? Use
.release()orrelease_persistent_boxes(mut list).- This is the only place where you MUST be careful. Anything in a persistent struct needs an explicit release in the struct's cleanup stage.
- Using V objects in PHP? Just use them.
- No more "Middleware is not valid" errors. The bridge-level root registry makes V objects safely pinnable from the PHP side.
Preferred Construction Patterns
Request-scoped values
Use:
RequestOwnedZBox.new_null()RequestOwnedZBox.new_bool(...)RequestOwnedZBox.new_int(...)RequestOwnedZBox.new_float(...)RequestOwnedZBox.new_string(...)RequestOwnedZBox.of(z)when starting from an existing Zend value
Persistent scalar data
Use:
PersistentOwnedZBox.new_null()PersistentOwnedZBox.new_bool(...)PersistentOwnedZBox.new_int(...)PersistentOwnedZBox.new_float(...)PersistentOwnedZBox.new_string(...)PersistentOwnedZBox.of_data(...)PersistentOwnedZBox.try_of_detached(...)for scalar/array/map payloads that do not contain object/resource referencesPersistentOwnedZBox.of_mixed(...)when detached data is preferred but mixed values still need a compatibility fallback
These constructors should remain detached from raw ZVal.new_*() allocation.
Persistent objects
Use:
RetainedObject.from_zval(...)PersistentOwnedZBox.of(...)only when object routing is explicitly intended to become a retained-object variant
Persistent callables
Use:
PersistentOwnedZBox.from_callable_zval(...)PersistentOwnedZBox.of_callable(...)
These route long-lived callable forms into the retained-callable model:
- function-name strings
['ClassName', 'method'][$object, 'method']- invokable objects /
Closure
Constructor Semantics
PersistentOwnedZBox.of(z)
- Friendly general entry point
- Accepts any
ZVal - Routes objects into retained handles
- Routes safe pure data into detached storage when possible
- Still allows narrow compatibility fallback for mixed legacy values
PersistentOwnedZBox.of_data(value)
- Use when the caller already has detached
DynValue - No Zend lifecycle dependency
PersistentOwnedZBox.try_of_detached(z)
- Use when the input is expected to be pure detachable data
- Returns
noneif the payload contains object/resource references
PersistentOwnedZBox.of_mixed(z)
- Use when the value should prefer detached storage, but mixed inputs still need a compatibility fallback
Summary
request vs persistent describes lifetime.
borrowed vs owned describes responsibility.
These are independent axes. A correct API should make both obvious.
Design Direction
The long-term direction is:
- keep raw
ZValmanipulation inside bridge-level helpers - let upper layers consume
*ZBoxwrappers, detached data, or retained handles - make the safe ownership path the easiest default API
- keep ownership reasoning centered on
*ZBox, detached data, and retained handles
If a new API returns ZVal, its ownership contract must be obvious from the
name or documentation.