Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
641422c
Add microbenchmarks that check how internals scale
ondrejmirtes Nov 13, 2026
21a88e9
Skip work in MutatingScope's hottest methods when the answer is known
ondrejmirtes Nov 14, 2026
a9aae1e
Add microbenchmarks of the analyser and PHPDoc performance fixes of 2026
ondrejmirtes Nov 14, 2026
db04199
Mark only the new level of a chain that has no nullsafe operator
ondrejmirtes Nov 14, 2026
c7053e2
Enter the declaration once per PHPDoc type, not once per node
ondrejmirtes Nov 14, 2026
a5cd2eb
Create UnmatchedIgnoredErrorJudge only for an entry that is reported
ondrejmirtes Nov 14, 2026
221ce13
Skip the predicate template pass and repeated class recording in refl…
ondrejmirtes Nov 14, 2026
a1aea47
Return the one array range() can return without unioning it again
ondrejmirtes Nov 14, 2026
571e468
Intersect with object in restricted usage rules only when the type is…
ondrejmirtes Nov 14, 2026
e56ea96
Restore the type system performance of earlier releases
ondrejmirtes Nov 14, 2026
2a278a4
Scan a small flattened union instead of indexing it in TypeCombinator…
ondrejmirtes Nov 14, 2026
0dbc535
Describe each conditional expression part once when merging scopes
ondrejmirtes Nov 14, 2026
f552f3a
Skip repeated work in IsSuperTypeOfResult, ObjectType and union sorting
ondrejmirtes Nov 14, 2026
cc977ec
Cache the ASTs of PHP 8 stubs again
ondrejmirtes Nov 14, 2026
3ab77a5
Do not report the unmatched BenchEnum ignore on PHP 8.1
ondrejmirtes Nov 14, 2026
2c4bd3a
Keep the microbenchmarks parseable on PHP 7.4 and 8.0
ondrejmirtes Nov 14, 2026
0fc19ea
Declare TypeCombinator::ALREADY_NORMALIZED_SCAN_LIMIT in the turbo mi…
ondrejmirtes Nov 14, 2026
633c6d1
Accept the benchmark numbers of CI
ondrejmirtes Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ New introduced classes in these categories should be shadowed with a native C++
- **Always verify the test fails before the fix.** Stating "tests pass" is not enough — that only proves the fix doesn't break things. `git stash` the source change, run the test, see it fail for the right reason; `git stash pop`, run again, see it pass. Only then is the fix confirmed. The `regression-test` skill bakes this in for bug-issue workflows.
- **Reproducers come from the OP's playground link** in the issue body, not from later comments. Copy the snippet as close to verbatim as practical.
- **Integration tests**: `AnalyserIntegrationTest` runs full analysis on test files and checks error output.
- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept <directory>`. The configuration is `tally.php`.
- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept <directory>`. The configuration is `tally.php`. The other `tests/bench/*Bench.php` classes are microbenchmarks of internals such as `TypeCombinator` and `ConstantArrayType`: they boot the container with `BenchContainer` in a `#[Before]` method, build their inputs there too, and declare how the instructions grow with the input (`#[Linear]`, `#[Quadratic]`, `#[Complexity]`, …) instead of expected numbers. Warm up on different inputs than the counted call, because the type system memoizes results.

### Adding support for new PHP versions

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ lint:
--exclude tests/PHPStan/Analyser/data \
--exclude tests/bench/data \
--exclude tests/bench/Warmup.php \
--exclude tests/bench/BenchEnum.php \
--exclude tests/PHPStan/Analyser/nsrt \
--exclude tests/PHPStan/Rules/Methods/data \
--exclude tests/PHPStan/Rules/Functions/data \
Expand Down
7 changes: 6 additions & 1 deletion build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,13 @@ parameters:
-
# Tally calls the benchmark methods, their data providers and #[Before] methods
identifier: shipmonk.deadMethod
path: ../tests/bench/AnalysisBench.php
path: ../tests/bench/*Bench.php
reportUnmatched: false
-
# the benchmarks name the cases by string, C0 to C255
identifier: shipmonk.deadEnumCase
path: ../tests/bench/BenchEnum.php
reportUnmatched: false # the dead code detector runs on PHP 8.2+ only
-
identifier: shipmonk.deadConstant
paths:
Expand Down
4 changes: 1 addition & 3 deletions build/tally-pre-8.1.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,4 @@ parameters:
excludePaths:
# Tally's sources use enums and readonly properties, which PHPStan does not parse for PHP < 8.1
- ../tally.php
- ../tests/bench/AnalysedFileExpectations.php
- ../tests/bench/AnalysedFileHeader.php
- ../tests/bench/AnalysisBench.php
- ../tests/bench/*
11 changes: 10 additions & 1 deletion conf/parsers.neon
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ services:
arguments:
currentPhpVersionRichParser: @cachedRichParser
currentPhpVersionSimpleParser: @cachedSimpleParser
php8Parser: @php8Parser
php8Parser: @cachedPhp8Parser
singleReflectionFile: %singleReflectionFile%
autowired: false

Expand Down Expand Up @@ -83,6 +83,15 @@ services:
cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax%
autowired: false

# reflection asks for the nodes of a stub again for each symbol it declares
cachedPhp8Parser:
class: PHPStan\Parser\CachedParser
arguments:
originalParser: @php8Parser
cachedNodesByStringCountMax: %cache.nodesByStringCountMax%
cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax%
autowired: false

defaultAnalysisParser:
factory: @pathRoutingParser
autowired: false
Expand Down
1 change: 1 addition & 0 deletions src/Analyser/ConditionalExpressionHolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function getTypeHolder(): ExpressionTypeHolder
return $this->typeHolder;
}

/** ScopeOps::createConditionalExpressions() builds the same key from parts; keep the two in sync. */
public function getKey(): string
{
$parts = [];
Expand Down
21 changes: 11 additions & 10 deletions src/Analyser/Ignore/IgnoredErrorHelperResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -368,23 +368,24 @@ public function process(
$analysedFilesKeys = array_fill_keys($analysedFiles, true);

if (!$hasInternalErrors && !$collectedDataRuleSkipped) {
// asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry
$judge = new UnmatchedIgnoredErrorJudge(
$this->fileHelper,
$this->analyseFileExcluder,
$analysedFiles,
$this->analysedPaths,
$this->analysedPathsFromConfig,
$collectedData,
$onlyFiles,
);
$judge = null;
foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) {
$reportUnmatched = is_array($unmatchedIgnoredError)
? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors)
: $this->reportUnmatchedIgnoredErrors;
if ($reportUnmatched === false) {
continue;
}
// asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry
$judge ??= new UnmatchedIgnoredErrorJudge(
$this->fileHelper,
$this->analyseFileExcluder,
$analysedFiles,
$this->analysedPaths,
$this->analysedPathsFromConfig,
$collectedData,
$onlyFiles,
);
$realCount = $realCounts[$i] ?? null;
if (
isset($unmatchedIgnoredError['count'])
Expand Down
23 changes: 19 additions & 4 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,12 @@ public function hasVariableType(string $variableName): TrinaryLogic
/** @api */
public function getVariableType(string $variableName): Type
{
// a certainly defined variable, the common case, does not need the rest
$holder = $this->expressionTypes['$' . $variableName] ?? null;
if ($holder !== null && $holder->getCertainty()->yes()) {
return $holder->getType();
}

$hasVariableType = $this->hasVariableType($variableName);

if ($hasVariableType->maybe()) {
Expand Down Expand Up @@ -977,6 +983,12 @@ public function getScopeNativeType(Expr $expr): Type

public function getNodeKey(Expr $node): string
{
// ScopeOps::nodeKey() has the same fast path, but this method is hot enough
// that the static call and the services fetch before it are measurable
if ($node instanceof Variable && !$node->name instanceof Expr) {
return '$' . $node->name;
}

return ScopeOps::nodeKey($node, $this->services->exprPrinter);
}

Expand Down Expand Up @@ -3562,7 +3574,7 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type

public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self
{
if ($this->isSpecifyExpressionTypeNoop($expr, $type)) {
if ($expr instanceof Scalar || $this->isSpecifyExpressionTypeNoop($expr, $type)) {
return $this;
}

Expand Down Expand Up @@ -4335,10 +4347,13 @@ public function isInFirstLevelStatement(): bool

public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self
{
$merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope?->getTemplateArgumentConstraints());
if ($otherScope === null || $otherScope === $this) {
return $this;
}

$merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope->getTemplateArgumentConstraints());
if (
$otherScope === null
|| $merged->templateArgumentConstraints === null
$merged->templateArgumentConstraints === null
|| $merged->templateArgumentFrame === null
|| !$merged->templateArgumentFrame->isObservingClosures()
) {
Expand Down
14 changes: 8 additions & 6 deletions src/Analyser/NullsafeOperatorHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ public static function getNullsafeShortcircuitedExpr(Expr $expr): Expr
&& !$current instanceof Expr\NullsafePropertyFetch
) {
$next = self::chainedInto($current);
if ($next === null) {
// Every level of a chain is resolved in turn and each one used to
// walk down to the root again, so a chain of depth N cost O(N^2)
// walk steps — with no nullsafe operator in it at all. Marking
// the levels on the way out makes that O(N) per chain.
for ($level = $expr; $level !== null; $level = self::chainedInto($level)) {
if ($next === null || ($current === $expr && $next->getAttribute(self::SHORTCIRCUITED_ATTRIBUTE) === true)) {
// Every level of a chain is resolved in turn, innermost first, and
// each one used to walk down to the root again, so a chain of depth N
// cost O(N^2) walk steps — with no nullsafe operator in it at all.
// A marked level has none at or below it: when the level right below
// is marked, as it is when the chain is resolved innermost first, only
// this level is marked, which makes that O(N) per chain.
for ($level = $expr; $level !== null && $level !== $next; $level = self::chainedInto($level)) {
$level->setAttribute(self::SHORTCIRCUITED_ATTRIBUTE, true);
}

Expand Down
79 changes: 56 additions & 23 deletions src/Analyser/ScopeOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_filter;
use function array_key_exists;
use function array_key_first;
Expand Down Expand Up @@ -389,6 +390,26 @@ public static function createConditionalExpressions(
): array
{
$newVariableTypes = $ourExpressionTypes;
$differingExprStrings = array_keys($differingKeys);

// A guard needs its expression certainly defined in both branches. Collect
// those keys once - the guard loop below walks only them, and without any
// neither target loop can add a holder, which skips the passes over every key.
$guardCandidates = [];
foreach ($differingExprStrings as $exprString) {
if (
!array_key_exists($exprString, $newVariableTypes)
|| !array_key_exists($exprString, $theirExpressionTypes)
|| !$newVariableTypes[$exprString]->getCertainty()->yes()
|| !$theirExpressionTypes[$exprString]->getCertainty()->yes()
) {
continue;
}
$guardCandidates[] = $exprString;
}
if ($guardCandidates === []) {
return $conditionalExpressions;
}

// A guard is only ever consumed paired with a target: a *different* key in
// the first target loop below, any key in the second one. Deriving a guard
Expand All @@ -399,7 +420,7 @@ public static function createConditionalExpressions(
// set instead of repeating the checks.
$targets = [];
$hasUndefinedTarget = false;
foreach (array_keys($differingKeys) as $exprString) {
foreach ($differingExprStrings as $exprString) {
if (!array_key_exists($exprString, $newVariableTypes)) {
if (
array_key_exists($exprString, $mergedExpressionTypes)
Expand Down Expand Up @@ -432,7 +453,7 @@ public static function createConditionalExpressions(
// valid conditional *target*; the flag also lets the target loop skip
// pairing these absorbed targets with constant-array guards.
$guardsToExclude = [];
foreach (array_keys($differingKeys) as $exprString) {
foreach ($differingExprStrings as $exprString) {
if (!array_key_exists($exprString, $theirExpressionTypes)) {
continue;
}
Expand All @@ -457,34 +478,22 @@ public static function createConditionalExpressions(
}

$typeGuards = [];
foreach (array_keys($differingKeys) as $exprString) {
if (!array_key_exists($exprString, $newVariableTypes)) {
continue;
}
// with no their-branch entry the merged holder keeps our type with lowered
// certainty, so no later type assertion can tell the branches apart - the
// candidates are certain in both branches
foreach ($guardCandidates as $exprString) {
$holder = $newVariableTypes[$exprString];
if ($holder->getExpr() instanceof VirtualNode) {
continue;
}
if (!array_key_exists($exprString, $mergedExpressionTypes)) {
continue;
}
if (!$holder->getCertainty()->yes()) {
continue;
}
if (array_key_exists($exprString, $guardsToExclude)) {
continue;
}

if (!array_key_exists($exprString, $theirExpressionTypes)) {
// with no their-branch entry the merged holder keeps our type with
// lowered certainty, so no later type assertion can tell the
// branches apart
continue;
}
$theirHolder = $theirExpressionTypes[$exprString];
if (!$theirHolder->getCertainty()->yes()) {
continue;
}
if ($holder->equalTypes($theirHolder)) {
continue;
}
Expand Down Expand Up @@ -530,6 +539,13 @@ public static function createConditionalExpressions(
$guardIsSuperTypeOfTheirExprCache = [];
$theirExprIsSuperTypeOfGuardCache = [];

// The holders below are keyed by ConditionalExpressionHolder::getKey(),
// which describes both the guard and the target type of each pair. Build
// the same key from the guard part described once per guard and the
// target part described once per target instead of once per pair - the
// descriptions of big constant arrays dominate the merge otherwise.
$guardKeyPrefixes = [];

foreach (array_keys($targets) as $exprString) {
$holder = $newVariableTypes[$exprString];

Expand All @@ -540,6 +556,8 @@ public static function createConditionalExpressions(
continue;
}

$targetKeySuffix = null;

$exprIsGuardExcluded = array_key_exists($exprString, $guardsToExclude);
foreach ($variableTypeGuards as $guardExprString => $guardHolder) {
// A subtype-absorbed target (kept only for re-narrowing) paired with a
Expand Down Expand Up @@ -578,12 +596,13 @@ public static function createConditionalExpressions(
}
}

$conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder);
$conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression;
$targetKeySuffix ??= self::conditionalExpressionKeySuffix($holder);
$guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder);
$conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder);
}
}

foreach (array_keys($differingKeys) as $exprString) {
foreach ($differingExprStrings as $exprString) {
if (!array_key_exists($exprString, $mergedExpressionTypes)) {
continue;
}
Expand All @@ -595,15 +614,29 @@ public static function createConditionalExpressions(
continue;
}

$undefinedHolder = new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo());
$targetKeySuffix = self::conditionalExpressionKeySuffix($undefinedHolder);
foreach ($typeGuards as $guardExprString => $guardHolder) {
$conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo()));
$conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression;
$guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder);
$conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $undefinedHolder);
}
}

return $conditionalExpressions;
}

/** The part of ConditionalExpressionHolder::getKey() a single-guard holder's guard contributes. */
private static function conditionalExpressionKeyPrefix(string $guardExprString, ExpressionTypeHolder $guardHolder): string
{
return $guardExprString . '=' . $guardHolder->getType()->describe(VerbosityLevel::precise());
}

/** The part of ConditionalExpressionHolder::getKey() the target holder contributes. */
private static function conditionalExpressionKeySuffix(ExpressionTypeHolder $typeHolder): string
{
return ' => ' . $typeHolder->getType()->describe(VerbosityLevel::precise()) . ' (' . $typeHolder->getCertainty()->describe() . ')';
}

/**
* The part of our branch type the other branch cannot produce.
*
Expand Down
9 changes: 5 additions & 4 deletions src/Dependency/DependencyResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,16 @@ public function resolveFileDependencies(?Dependencies $dependencies, array $anal
$functionNames = [];
foreach ($reflectionsByFile as $file => $reflections) {
foreach ($reflections as $id => $reflection) {
if ($reflection instanceof FunctionReflection) {
$functionNames[$reflection->getName()] = $reflection->getName();
// classes first, they are most of the reflections
if ($reflection instanceof ClassReflection) {
$classReflections[$id] = $reflection;
continue;
}
if (!$reflection instanceof ClassReflection) {
if (!$reflection instanceof FunctionReflection) {
continue;
}

$classReflections[$id] = $reflection;
$functionNames[$reflection->getName()] = $reflection->getName();
}
$this->resolveFiles($file, $reflections, $analysedFiles, $fileDependencies, $packages);
}
Expand Down
Loading
Loading