VPHP Value Layers

May 18, 2026 ยท View on GitHub

This document explains how VPHP value types relate to each other.

Use it when choosing between:

  • ZVal
  • RequestBorrowedZBox / RequestOwnedZBox / PersistentOwnedZBox
  • PhpValue / PhpInt / PhpString / PhpArray / PhpObject / ...
  • PhpArg / PhpArgInput
  • RequestScope / FrameScope

One Value, Several Questions

A PHP value crosses several boundaries before application code should touch it. Each layer answers a different question:

flowchart TD
    ZEND["Zend value<br/>C.zval / zend_object / zend_array"] --> ZVAL["ZVal<br/>low-level Zend handle"]

    ZVAL --> ZBOX["ZBox lifecycle layer"]
    ZBOX --> SEM["PHP semantic wrappers"]
    SEM --> ARG["Argument / call ergonomics"]
    ARG --> SCOPE["Scope helpers"]

    ZBOX --> RB["RequestBorrowedZBox"]
    ZBOX --> RO["RequestOwnedZBox"]
    ZBOX --> PO["PersistentOwnedZBox"]

    SEM --> PV["PhpValue"]
    SEM --> PS["PhpInt / PhpString / PhpBool / PhpDouble / PhpNull"]
    SEM --> PC["PhpArray / PhpObject / PhpCallable / PhpClosure / PhpResource"]

    ARG --> IN["PhpArg / PhpArgs"]
    ARG --> FN["PhpArgInput"]

    SCOPE --> REQ["RequestScope"]
    SCOPE --> FRAME["FrameScope"]
LayerMain typesQuestion answered
ZendC.zval, zend_object, zend_arrayWhat does the PHP engine store?
Low-level handleZValHow does V touch the Zend value?
LifecycleRequestBorrowedZBox, RequestOwnedZBox, PersistentOwnedZBoxHow long can this value live, and who releases it?
PHP semanticsPhpValue, PhpInt, PhpArray, PhpObject, ...What PHP type does this value mean?
ArgumentsPhpArg, PhpArgInputDoes this value carry PHP input metadata, or is it accepted as a PHP call argument?
ScopesRequestScope, FrameScopeWhere are temporary request-owned values released?

The important rule: do not use a lower layer when a higher layer expresses the intent clearly.

ZVal

ZVal is the bridge-level escape hatch over a Zend value.

Use it for:

  • C / Zend bridge helpers
  • raw interop code with explicit _zval APIs
  • implementing semantic wrappers
  • places where PHP key type or low-level zval state must be inspected directly

Avoid it in extension application code when a semantic wrapper can say the same thing:

// Prefer this in application code.
fn greet(name vphp.PhpString) string {
	return 'Hello ${name.value()}'
}

// Keep this style in bridge-level code.
fn greet_raw(name vphp.ZVal) string {
	return 'Hello ${name.to_string()}'
}

*ZBox

ZBox types add lifecycle and ownership to a Zend value.

TypeMeaningTypical sourceRelease rule
RequestBorrowedZBoxBorrowed view of a request valuePHP arguments, borrowed wrappersDo not release
RequestOwnedZBoxTemporary value owned in the current requestPHP call result, RequestOwnedZBox.new_*, adopted zvalRelease once or hand to a scope
PersistentOwnedZBoxLong-lived detached data or retained handleapp state, route metadata, cached callablesOwner must release

Request-owned values are for the current request or call flow. Persistent-owned values are for long-lived storage. Converting from request to persistent is an ownership/lifecycle decision, not a PHP type decision.

Common constructors:

borrowed := vphp.RequestBorrowedZBox.of(z)
owned := vphp.RequestOwnedZBox.of(z)
stored := vphp.PersistentOwnedZBox.of(z)

When the input kind is known, prefer the explicit persistent constructor:

stored_data := vphp.PersistentOwnedZBox.of_data(dyn)
stored_object := obj.retain()
stored_callable := callable.retain()

In extension-facing and framework-facing code, prefer value.retain() when the intent is "keep this PHP value beyond the current request frame". Lower-level to_persistent_owned() / PersistentOwnedZBox.* APIs remain lifecycle storage tools for vphp internals and ownership-sensitive boundaries.

For detached runtime storage data, use:

stored := vphp.DynValue.persistent_owned_zbox(value)

value may be:

  • DynValue
  • RetainedObject for internal object-retention plumbing
  • RetainedCallable for internal callable-retention plumbing

PHP Semantic Wrappers

Semantic wrappers describe PHP type intent.

WrapperMeaning
PhpValuemixed PHP value
PhpNull, PhpBool, PhpInt, PhpDouble, PhpStringscalar/null PHP values
PhpArrayPHP array
PhpObjectPHP object
PhpCallable, PhpClosurecallable / closure semantics
PhpResourcePHP resource
PhpIterator, PhpIterable, PhpReference, PhpThrowable, PhpEnumCasespecialized PHP semantics

PhpIterator means a PHP object that implements Traversable; PhpIterable means the PHP iterable shape: PhpArray | PhpIterator.

These wrappers are built on top of PhpValueZBox, so they can carry borrowed, request-owned, or persistent-owned storage while presenting a PHP type API.

Examples:

value := vphp.PhpValue.from_zval(raw)
name := value.require_string()!

arr := vphp.PhpArray.empty()
arr.string('name', 'codex')

obj := vphp.PhpObject.borrowed(raw_obj)
title := obj.method[vphp.PhpString]('title')!

Use with_result(...) / with_method_result(...) for complex temporary return values so the lifecycle stays inside the callback:

count := vphp.PhpFunction.named('array_filter').with_result[vphp.PhpArray, int](
	fn (filtered vphp.PhpArray) int {
	return filtered.count()
}, items)!

Use call[T] / method[T] for copied scalar wrappers:

length := vphp.PhpFunction.named('strlen').call[vphp.PhpInt](vphp.PhpString.of('codex'))!

PhpArg And PhpArgInput

These names separate the normalized argument object from the broad input shape accepted by PHP call helpers.

PhpArg

PhpArg means: "a PHP argument value, optionally with input metadata".

It carries:

  • value PhpValue
  • optional PhpArgMeta with index, name, and attributes

PhpAttribute is intentionally generic. PhpArgMeta.attributes is only one mount point; class, function, method, property, or return metadata can reuse the same []PhpAttribute model when those runtime meta wrappers grow attributes. PhpAttribute.target records the current mount point. ctx.args_with_meta(...) normalizes missing attribute targets to .parameter.

Use it when the function needs dynamic access to the original PHP argument list:

arg := ctx.arg_at(0)
name := arg.name()
value := arg.string_value() or { vphp.PhpString.empty() }

Context exposes two collection helpers:

args := ctx.args()
args_with_decl := ctx.args_with_meta([
	vphp.PhpArgMeta{
		index: 0
		name:  'query'
		attributes: [
			vphp.PhpAttribute.named('FromQuery').string('q'),
			vphp.PhpAttribute.named('Range').int(1).int(100),
		]
	},
])

ctx.args() reads the actual PHP arguments only. ctx.args_with_meta(...) attaches declaration metadata supplied by generated glue or by a hand-written Context function.

Parameter attributes are available through PhpArg:

query := args_with_decl.at(0)
if from_query := query.attr('FromQuery') {
	source := from_query.items[0].value
}

if query.has_attr('Range') {
	// inspect query.attr('Range')!.items
}

Normally, exported V functions should prefer typed parameters instead:

@[php_function]
fn hello(name vphp.PhpString, count int) string {
	return '${name.value()}:${count}'
}

PhpArgInput

PhpArgInput means: "a semantic value shape accepted when V calls a PHP function, method, or callable".

It is a sum type over semantic wrappers, plus PhpArg for pass-through cases:

pub type PhpArgInput = PhpArray
	| PhpArg
	| PhpBool
	| PhpCallable
	| PhpClosure
	| PhpDouble
	| PhpEnumCase
	| PhpInt
	| PhpIterable
	| PhpIterator
	| PhpNull
	| PhpObject
	| PhpReference
	| PhpResource
	| PhpScalar
	| PhpString
	| PhpThrowable
	| PhpValue

That lets V call PHP with semantic values directly:

result := vphp.PhpFunction.named('sprintf').call[vphp.PhpString](
	vphp.PhpString.of('hello %s'),
	vphp.PhpString.of('codex'),
)!

Rule of thumb:

DirectionType
PHP -> V argument metadataPhpArg / PhpArgs
V -> PHP call argument inputPhpArgInput
Already typed V-exported function parameterPhpString, PhpObject, int, string, etc.

Scopes

RequestScope

RequestScope manages request-owned values for a request/call boundary.

Compiler-generated glue uses:

mut vphp_scope := vphp.PhpScope.once()
defer {
	vphp_scope.close()
}

Use PhpScope.request() when a larger request-level window is needed. Use PhpScope.once() for one call boundary.

Most extension code should not need to call low-level autorelease functions directly.

FrameScope

FrameScope is for building temporary PHP call arguments inside one V frame.

It owns the request-owned boxes it creates and releases them all at once:

mut frame := vphp.PhpScope.frame()
defer {
	frame.release()
}

args := [
	frame.string('codex'),
	frame.int(42),
]

mut result := vphp.PhpFunction.named('handler').invoke(...args)

Use FrameScope.args_from_persistent_owned(...) when long-lived stored values need to be passed into a request-time PHP callable:

mut frame := vphp.PhpScope.frame()
defer {
	frame.release()
}

args := frame.args_from_persistent_owned(stored_args)
mut result := handler.invoke(...args)

FrameScope is not persistent storage. It is a short-lived conversion and cleanup helper.

Common Flows

PHP Calls V

flowchart LR
    PHP["PHP call"] --> CTX["Context / ZExData"]
    CTX --> INARGS["PhpArgs / PhpArg"]
    INARGS --> SEM["Typed params<br/>PhpString / PhpObject / int / string"]
    SEM --> V["V function body"]

Supported parameter styles:

StyleExampleUse when
Contextfn handle(ctx vphp.Context)Need raw execute data or manual arg access
V scalarsfn add(a int, b string)Simple scalar parameters
PHP semantic wrappersfn run(argv vphp.PhpIterable)Want PHP type semantics
optional paramsfn value(default ?vphp.PhpValue)Missing argument matters
@[params] structfn create(p CreateParams)Named/default argument groups

Do not combine Context with PhpArgs as public parameters. PhpArgs is derived from the context inside glue through ctx.args() or ctx.args_with_meta(...).

V Calls PHP

flowchart LR
    V["V code"] --> SEM["PhpValue / PhpString / PhpObject"]
    SEM --> ARG["PhpArgInput"]
    ARG --> CALL["PhpFunction / PhpObject / PhpClass"]
    CALL --> RESULT["RequestOwnedZBox or with_result callback"]

Prefer semantic call APIs:

upper := vphp.PhpFunction.named('strtoupper').call[vphp.PhpString](
	vphp.PhpString.of('codex'),
)!

sent := conn.with_method_result[vphp.PhpValue, bool]('send',
	fn (result vphp.PhpValue) bool {
	return result.is_valid()
}, vphp.PhpString.of('payload'))!

Use _zval APIs only at bridge boundaries or when the raw zval behavior is the point of the code.

Store For Later

flowchart LR
    NOW["Request-time value"] --> CHECK["Known kind?"]
    CHECK --> DATA["Detached data<br/>DynValue"]
    CHECK --> OBJ["PhpObject.retain()"]
    CHECK --> CALL["PhpCallable.retain()"]
    DATA --> PZ["PersistentOwnedZBox"]
    OBJ --> PZ
    CALL --> PZ

Use:

stored := value.retain()

or, at a lower-level lifecycle boundary where detached runtime storage is the actual target:

stored := vphp.DynValue.persistent_owned_zbox(ref.clone())

The owner of a retained semantic value or PersistentOwnedZBox must have a matching release() path.

Naming Rules

Current naming direction:

PatternMeaning
from_zval(...)Build a semantic wrapper view from an existing ZVal
from_request_owned_zbox(...)Build a semantic wrapper from RequestOwnedZBox
from_persistent_owned_zbox(...)Build a semantic wrapper from PersistentOwnedZBox
from_persistent_zval(...)Preserve a persistent zval payload inside a semantic wrapper
to_zval()Expose the underlying zval view
zval()Read the underlying ZVal from a PHP input argument
zbox()Borrow a PHP input argument as RequestBorrowedZBox
to_request_owned()Materialize an owned request box
persistent_owned_zbox(...)Produce PersistentOwnedZBox
invoke(...) / call_method(...) / call_static(...)PHP call result as a semantic PhpValue
with_result(...)Borrow a result inside a callback
_zval suffixLow-level escape hatch

Names should reveal both type semantics and lifecycle semantics. If a helper returns PersistentOwnedZBox, its name should say zbox, not just box.

Remaining Edges To Clean Up

The model is now coherent, but a few edges are still worth tightening:

  1. with_request_zval(...) still exists in vphp internals. That is reasonable at the lifecycle boundary, but new application code should prefer semantic helpers such as with_request_value, with_request_array, or with_request_object.
  2. Generated glue still has to touch Context and raw argument slots. That is a compiler boundary detail; public extension signatures should continue moving toward typed scalar/semantic wrappers and @[params] structs.