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.