Skip to content

Describe how the types of consecutive static variables depend on each other - #6619

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
static-variable-conditional-types
Sep 27, 2026
Merged

ondrejmirtes merged 2 commits into
2.3.xfrom
static-variable-conditional-types

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

The types of static variables (#6617, bleeding edge) are inferred one by one, so a cache keyed by one variable and holding its value in another loses the link between them (playground):

function getCurrentSpecialEvent(string $key): string
{
	static $interimCacheKey = null;
	static $interimCacheValue = null;

	$now = new DateTimeImmutable();
	$cacheKey = sprintf('%s-%s', $key, $now->format('YmdHi'));

	if ($interimCacheKey !== $cacheKey) {
		$interimCacheKey = $cacheKey;
		$interimCacheValue = uniqid();
	}

	return $interimCacheValue; // should return string but returns string|null
}

The value is null only while the key is. This PR teaches the static-variable inference to describe that with ConditionalExpressionHolders between the variables.

How it works

  • Runs. StaticVariableInference::getRuns() finds runs of consecutive top-level static statements declaring two or more inferred variables (one statement with several variables counts too). Nothing between the statements of a run can change what the previous calls left, so right after the run the variables hold their defaults, or one of the states a call left them in together.

  • States. The two-pass driver collects those joint states:

    • the end of the body;
    • returns;
    • explicit throws;
    • calls that could run the function again: anything but a built-in function none of whose parameters takes a Closure;
    • the entries of the statements the walk carries over.

    It skips unreachable scopes (a never variable) and scopes where the variables still hold exactly what the walk entered the run with.

  • Holders. For each type a variable holds in some state, the condition on another variable is what that variable holds in none of the states that leave the type out, taken from everything it can hold. The target type gets the same remainder treatment. This is the remainder form MutatingScope::mergeWith() uses for its guards, so the holders cover a variable whose type keeps generalizing, like a counter:

    static $count = 0;
    static $last = null;
    if ($count === 0) { /* $last is null */ } else { /* $last is 'x' */ }
    $count++;
    $last = 'x';
  • Fixpoint. StaticVariableHandler adds the holders after the run's last statement. The walks enter the run with them until the holders converge along with the types. If they don't, they're given up and the walks go on without them.

Soundness cases the fixture pins, where the link must not be claimed:

  • a user function called between the two assignments (it could re-enter and see the key without the value);
  • an explicit throw between them;
  • statements that are not adjacent.

Verification

  • tests/PHPStan/Analyser/nsrt/static-variables-conditional-types.php holds the playground snippet verbatim plus the cases above. It fails before the change for the right reasons.
  • make tests is green with the extension off and loaded. Walk-trace (PHP vs native) is identical on the full corpus and on the fixtures. Side-by-side, smoke and signature parity pass. make phpstan and cs are clean.
  • Native mirrors: StaticVariableInference, TemplateArgumentFrame, StatementsHandler, StaticVariableHandler.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv

ondrejmirtes and others added 2 commits September 27, 2026 16:12
… other

The type of each `static` variable is inferred on its own, so a cache
keyed by one variable and holding its value in another loses the link
between them: after `if ($cachedKey !== $key) { $cachedKey = $key;
$cachedValue = compute(); }` the value is still `string|null`, although it
is null only while the key is.

Nothing between the statements of a run of consecutive top-level `static`
statements can change what the previous calls left in their variables, so
right after the run they hold their defaults or one of the states a call
left them in together. The two-pass driver collects those states - at the
end of the body, at returns and explicit throws, and at calls that could
run the function again (anything but a built-in function without a Closure
parameter) - and describes them with conditional expressions between the
run's variables, which StaticVariableHandler adds after the run. The walks
enter the run with them until they converge along with the types, or are
given up.

The conditions and target types take the remainder form MutatingScope::
mergeWith() uses for its guards - what a variable holds in no state that
leaves out the target type, of everything it can hold - so that they cover
the generalized types of a variable that keeps growing, like a counter.

Behind the staticVariablesFromUsages bleeding edge toggle, like the
inference of the types itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
@ondrejmirtes
ondrejmirtes merged commit b0d0456 into 2.3.x Sep 27, 2026
895 of 911 checks passed
@ondrejmirtes
ondrejmirtes deleted the static-variable-conditional-types branch September 27, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant