Skip to content

Infer the type of a static variable from what the function body does with it - #6617

Merged
ondrejmirtes merged 3 commits into
2.3.xfrom
static-variables-from-usages
Sep 27, 2026
Merged

ondrejmirtes merged 3 commits into
2.3.xfrom
static-variables-from-usages

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

A static $x without a @var tag was mixed. It now gets its type from what the function body does with it:

function counter(): int
{
	static $count = 0;
	\PHPStan\dumpType($count); // int<0, max>
	$count++;

	return $count;
}

function singleton(): Foo
{
	static $instance = null;
	\PHPStan\dumpType($instance); // Foo|null
	if ($instance === null) {
		$instance = new Foo();
	}

	return $instance;
}

Bleeding edge only (featureToggles.staticVariablesFromUsages). Like #6604, it runs inside the two-pass body walk of unresolvedTemplateArguments, which bleeding edge enables too.

How it works

Every call starts where a previous one left the variable. Its type at the static statement is therefore the default joined with every type the variable takes in the body: at the end, at every return, throw and yield, and at every node the walk reaches (a call there could run the function again, recursion included).

  • The two-pass driver (StatementsHandler::processBodyStmtNodesTwoPass) collects those types from the observation pass.
  • It then observes the body again from the first static statement until the type at every static statement holds everything the variable takes after it. Like pass 2, each round walks only the statements that read a variable whose type changed. The types are generalized like a loop's variables, within the loop iteration limit.
  • The template arguments and closure signatures of the body are resolved from the facts of that last round, so they see the converged types.
  • StaticVariableHandler assigns the converged type, or the default's type while observing.

These keep mixed:

  • bodies whose variables can change behind the analysis' back ($$name, extract(), parse_str(), include, eval);
  • generators, because a suspended call keeps the variable in any state;
  • variables typed by @var, taken by reference, or declared global.

StaticVarWithoutTypeRule (playground) is removed; it only existed because the type was mixed.

Turbo. The new StaticVariableInference service is shadowed natively, and the native mirrors of StatementsHandler, StaticVariableHandler and TemplateArgumentFrame are updated.

Verification

  • make tests is green with the extension off and loaded. make phpstan and cs are clean.
  • walk-trace.php shows the PHP and native walks identical. Side-by-side, smoke and signature parity pass.
  • Tests:
    • nsrt/static-variables-from-usages.php: counters, singletons, memoizing caches, loops, throws, by-ref closures, recursion, and the cases that keep mixed;
    • StaticVariablesFromUsagesToggleOffTest: legacy mixed with the toggle off.
  • Changed expectations, each an improvement: bug-1870.php, bug-13810.php, new-in-initializers.php, LegacyNodeScopeResolverTest (array{} instead of mixed), a new StrictComparisonOfDifferentTypesRuleTest error on a static (int<0, 1>|null) compared against a value it can never hold, and ReturnTypeAfterFinallyRuleTest, where a previous call leaves 'test' in a by-ref static, so the wrong return is now reported by ReturnTypeRule at the return itself.

Downstream

Measured on this change stacked on the previous revision of #6604/#6610, with turbo phars and user CPU:

  • Performance: phpstan-src self-analysis +1.2%, Slevomat −0.6% and ShipMonk +0.6%, both within noise.
  • ShipMonk: no new errors.
  • Slevomat: 20 new errors.
    • 7 real bugs:
      • two memoizing statics that are never written;
      • a static captured by value in a closure, so its cache never persists;
      • a static reused for an object of another type;
      • two array_udiff() results declared as list;
      • a nullable getSchemaName().
    • 12 true positives from declared types, the same a local variable would get: a Nette Presenter passed where the application's Presenter is expected, a nullable parseFilterValue(), a deprecation, a redundant array_values(), and five isset() checks made dead by regex patterns that are now known.
    • 1 false positive: two statics whose values are correlated (a cache key and a cache value).

Closes phpstan/phpstan#7066

🤖 Generated with Claude Code

https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv

ondrejmirtes and others added 2 commits September 27, 2026 14:19
…with it

A `static $x` without `@var` was `mixed`. Every call starts where a previous
one left the variable, so its type at the `static` statement is the default
joined with every type the variable takes in the body: at the end, at every
return, throw and yield, and at every call that could run the function
again (every node the walk reaches).

The two-pass driver collects those types from the observation pass and
observes the body again from the first `static` statement - walking only
the statements that read a variable whose type changed - until the type at
each `static` statement holds everything the variable takes after it,
generalized like a loop's variables. The template arguments and closure
signatures of the body are resolved from the facts of that last walk, so
they observe the converged types.

Bodies whose variables can change behind the analysis' back ($$name,
extract(), parse_str(), include, eval), generators, and variables typed by
`@var`, taken by reference or declared `global` keep `mixed`.

StaticVarWithoutTypeRule (playground) is no longer needed.

Gated by featureToggles.staticVariablesFromUsages (bleeding edge).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
@ondrejmirtes
ondrejmirtes force-pushed the static-variables-from-usages branch from 660c3dc to a49a4e6 Compare September 27, 2026 12:19
Closes phpstan/phpstan#7066

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
@ondrejmirtes
ondrejmirtes marked this pull request as ready for review September 27, 2026 12:27
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

Static var type not recognized

2 participants