Language
Typed locals
Give variables a type, update their values safely, and use readonly when a value should not be replaced.Method signatures describe the inputs and result of a function. Typed locals bring the same clarity to its body: declare what a variable holds when you create it, and ++PHP checks every later assignment against that type.
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
An int variable continues to accept only integers after reassignment. If it also needs to represent an absent value, declare it as nullable:
?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
readonly prevents you from replacing the value of the variable. It does not prevent you from calling methods on an object stored there:
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 PHP readonly properties or an immutable class when the object's own data must not change. For example, a readonly $user variable always refers to the same user, but that user can still be renamed if its class allows it.
Use typed loop variables
Write the key and value types in foreach, and the counter type in for:
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 use PHP's enclosing scope. If you need a value after the loop, initialize it before the loop: an empty collection means the loop body never runs, so a variable first declared there might have no value.
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 body. A readonly local cannot be captured by reference.
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|null $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.
Fix local-variable errors
Start with the variable and line named in the diagnostic. If an assignment introduces a new variable, add its type; if the variable already exists, check the spelling and the value you are assigning. For a readonly error, remove the write or use a mutable declaration if reassignment is part of the task.
These codes identify the problem:
| 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.