4.1 KiB
#[Immutable] compile-time effect checking
Purpose
#[Immutable] is a TypePHP compile-time annotation modelled after C++ const.
It prevents accidental mutation in statically compiled code without adding a
wrapper object, Zend metadata, runtime branch, or ABI change.
It is intentionally a best-effort static tool rather than a security boundary. Calls whose target is deliberately made dynamic are an escape hatch and do not receive a runtime guard.
Supported targets
#[Immutable]
public function name(): string
{
return $this->name;
}
function inspect(#[Immutable] User $user): string
{
return $user->name();
}
The attribute is valid on methods and on function, method, and closure
parameters. On an instance method it makes $this immutable. On a parameter it
makes the binding immutable and, when it can contain an object, treats the
referenced object as immutable as well.
Rejected operations
For an immutable root such as $this or $user, the compiler rejects:
- assignment, destructuring, and array-element or object-property writes;
- compound assignment,
++,--,unset(), taking a reference, andforeach (... as &$value); - a statically named method call unless the resolved method is also marked
#[Immutable]; - a mutating value extension such as
$array->sort(); read-only array/string methods remain available; - passing an object to a statically resolved parameter that is not itself
#[Immutable]; - passing any immutable value to a mutable by-reference parameter, including
extension functions such as
sort(); - storing an immutable object identity in an object property, array, global/static variable, or returning/yielding it as a mutable value.
An immutable by-reference parameter is supported. It acts like a C++ const &:
the reference is accepted because the callee is checked against mutation.
#[MethodsFor] follows the same contract. An object extension is callable on
an immutable receiver only when its receiver parameter is marked
#[Immutable].
Aliases, closures, generators, and inheritance
Local aliases of immutable objects remain immutable:
$alias = $user;
$alias->rename('new'); // compile-time error
clone creates a distinct mutable object and therefore intentionally drops the
annotation. Captured variables, arrow functions, closure $this, and Fiber
generator bodies carry immutable metadata into their generated function
contexts.
An overriding class or interface method may strengthen an ordinary contract by
adding #[Immutable], but it cannot remove #[Immutable] from an inherited
method or parameter.
Value versus object semantics
Scalar values and PHP copy-on-write values can be read and copied normally. For
example, count($values) and $copy = $values do not modify an immutable array.
The compiler propagates immutability through an expression only when object
identity is possible.
Explicit escape hatches
The following intentionally bypass static method-effect checking:
$method = 'rename';
$user->$method('new');
$callable = getRuntimeCallable();
$callable($user);
The same applies to other runtime-only mechanisms that hide the target from the compiler, including reflection and dynamic ZendVM code. TypePHP neither inserts a runtime read-only proxy nor attempts to recover the escaped value later.
This boundary is deliberate: #[Immutable] should cost nothing in generated
code and should not complicate PHPX/ZendVM object semantics.
Property hooks and magic access
Property-hook reads are lowered to generated method calls. Consequently, a hook
used through an immutable receiver must itself carry an #[Immutable] method
contract; otherwise the generated call is rejected. Fully dynamic magic access
is covered by the same escape-hatch rule as other runtime-only behavior.
Implementation boundaries
The implementation is isolated in src/Immutable/ImmutableSupportTrait.php.
FunctionDef and ArgInfo retain only compile-time effect bits, while each
FunctionContext stores the immutable roots and object aliases relevant to that
body. Checks run during AST lowering and emit no C++ code when successful.