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.