Documentation
Typed locals
Give each local value one declared meaning, make mutation visible, and preserve that intent in emitted PHP.Method signatures describe what enters and leaves a unit of code. Most mistakes, however, happen between those boundaries: a variable appears through assignment, changes from an identifier into a nullable value, or is reused later for unrelated data. Typed locals make those transitions explicit.
Declare before you assign
An ordinary local declaration writes a type, a variable, and an initializer:
string $name = 'Andrew';
int $attempts = 0;
?int $result = null;
mixed $payload = loadPayload();
array $legacyItems = [];
readonly string $requestId = createRequestId();
Every declaration includes an initializer, so the variable begins with a valid value of its declared type.
Bare assignment never declares a local:
$attempts = 0; // P2002 Assignment Cannot Declare Variable
Once declared, assign without repeating the type:
int $attempts = 0;
$attempts = 1;
$attempts++;
This small distinction makes a typo or an unexpected control-flow path a compiler error instead of a new variable.
A local keeps one type
Later values must remain assignable to the written type:
int $attempts = 0;
$attempts = 4; // valid
$attempts = 'four'; // rejected
$attempts = null; // rejected
The compiler never widens int into int|string|null merely because incompatible assignments appeared later. If absence is part of the model, say so at the declaration:
?int $selectedPage = null;
$selectedPage = 4;
$selectedPage = null;
Use mixed when the variable genuinely crosses an untyped boundary, then narrow or validate it before passing the value into a stricter API.
Model stable decisions with readonly
A readonly local can be initialized once and read many times:
readonly Money $maximumRefund = $policy->maximumRefundFor($customer);
Money $requestedRefund = $form->requestedAmount();
if ($requestedRefund->isGreaterThan($maximumRefund)) {
throw new RefundLimitExceeded($maximumRefund);
}
The compiler rejects every operation that can replace or structurally mutate readonly storage:
readonly int $count = 0;
$count++; // rejected
unset($count); // rejected
readonly array $items = [];
$items[] = 'new'; // rejected
sort($items); // rejected: mutates by reference
References could mutate the variable from another location, so assignment by reference, passing a readonly local to a known by-reference parameter, and capturing it by reference are also rejected.
Readonly storage is not a frozen object
The modifier belongs to the local slot:
readonly User $user = new User('Andrew');
$user->rename('Lucy'); // object behavior remains available
$user->name = 'Lucy'; // governed by the property's own rules
$user = new User('Lucy'); // rejected: replaces the local
Use a native PHP readonly property or an immutable class design when the object itself must prohibit mutation. ++PHP does not pretend that one local declaration can deeply freeze an object graph.
Use typed loop variables
Loop-introduced values follow the same explicit model:
array<string, int> $scores = [
'Maya' => 92,
'Noor' => 88,
];
foreach ($scores as string $name => int $score) {
string $summary = $name . ': ' . $score;
echo $summary;
}
for (int $index = 0; $index < 3; ++$index) {
echo $index;
}
A list array<T> supplies int keys and T values. A map array<K, V> supplies the declared key and value types. Bare array supplies mixed loop variables because it makes no stronger promise.
Loop variables retain PHP's enclosing scope. After a loop that may run zero times, a newly declared loop variable may still be uninitialized; the compiler will not treat a possible iteration as a definite assignment.
Know which variables already exist
Parameters, catch variables, $this, PHP superglobals, and names introduced by property hooks already exist. Use them directly instead of redeclaring them as locals.
Closures have their own local scope. A capture must refer to an outer variable and carries that variable's type and mutability into the closure:
readonly string $prefix = 'Order';
Closure $label = function (int $number) use ($prefix): string {
return $prefix . ' #' . $number;
};
Ordinary if, loop, and try blocks do not create separate PHP variable scopes. Redeclaring the same local name inside one of those blocks is therefore an error rather than shadowing.
See what gets emitted
A declaration such as:
readonly ?Customer $customer = $repository->find($id);
becomes ordinary PHP:
/** @var ?Customer $customer */
$customer = $repository->find($id);
The compiler removes the local type and readonly modifier, preserves the initializer and surrounding source, and adds deterministic PHPDoc. Files without ++PHP-only syntax can remain byte-identical.
Diagnose the intent, not just the token
The P2xxx family distinguishes common mistakes:
| Code | Meaning |
|---|---|
P2002 |
bare assignment tried to declare a variable |
P2003 |
a local was read before declaration |
P2004 |
the scope contains a duplicate declaration |
P2005 |
readonly storage was reassigned |
P2006 |
readonly storage was structurally mutated |
P2007 |
readonly storage was referenced |
P2008 |
an initializer does not fit the declared type |
P2009 |
a later assignment does not fit the fixed type |
P2010 |
the variable declaration is unsupported in that position |
Diagnostics point to the original .ppphp declaration and include the related write when that relationship helps.
Continue with typed arrays to give collection locals precise element types, or when expressions to initialize one local from a multi-branch decision.