Skip to content

Fix phpstan/phpstan#14429: Treat PHPDocTypes as certain:false being ignored#5394

Open
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-qrjmws1
Open

Fix phpstan/phpstan#14429: Treat PHPDocTypes as certain:false being ignored#5394
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-qrjmws1

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When treatPhpDocTypesAsCertain: false is set, PHPStan incorrectly reported "will always evaluate to true" errors for type-checking functions like is_string() and is_int() on variables from foreach loops over generic objects (e.g. ArrayObject<string, string>). The native types were being contaminated with PHPDoc generic type information after any branch merge (if/else, loops, etc.).

Changes

  • Fixed ObjectType::equals() in src/Type/ObjectType.php to check that both types are the same concrete class using get_class(), preventing ObjectType from being considered equal to GenericObjectType with the same class name
  • Removed the now-redundant instanceof EnumCaseObjectType check (subsumed by the get_class check)
  • Removed the corresponding unmatched baseline entry in phpstan-baseline.neon
  • Added regression test in tests/PHPStan/Rules/Comparison/data/bug-14429.php

Root cause

ObjectType::equals() used $type instanceof self to verify type compatibility, but since GenericObjectType extends ObjectType, a plain ObjectType('ArrayObject') was considered equal to GenericObjectType('ArrayObject', [StringType, StringType]).

This equality bug was triggered by an optimization in MutatingScope::mergeWith() (lines 3392-3414) that shortcuts native type merging: when "our native type equals our expression type" AND "their native type equals their expression type", it uses the merged expression type as the merged native type. Due to the broken equals(), native ArrayObject|null was considered equal to PHPDoc ArrayObject<string, string>|null, causing the optimization to replace the native type with the PHPDoc-enriched merged expression type.

After any branch merge (if/else, foreach, while, etc.), native types of generic object variables got contaminated with PHPDoc generic information. With treatPhpDocTypesAsCertain: false, the ImpossibleCheckTypeHelper uses native types to determine if type checks are always true/false, so the contaminated native types led to false positive "always true" errors.

Test

Added a rule test that verifies no false positives are reported when treatPhpDocTypesAsCertain: false for is_string() and is_int() calls on foreach values from ArrayObject parameters with PHPDoc generic types.

Fixes phpstan/phpstan#14429

VincentLanglet and others added 2 commits April 3, 2026 22:26
- ObjectType::equals(GenericObjectType) incorrectly returned true when both had the same class name, because it didn't check whether generic type arguments differed
- This caused MutatingScope::mergeWith() native type optimization to replace native types (e.g. ArrayObject) with PHPDoc-enriched types (e.g. ArrayObject<string, string>), contaminating native type tracking after any branch merge
- With treatPhpDocTypesAsCertain: false, this led to false "will always evaluate to true" errors for is_string/is_int checks on foreach values from generic objects
- Added get_class equality check in ObjectType::equals() which also subsumes the old instanceof EnumCaseObjectType check
- Removed now-unneeded baseline entry for the EnumCaseObjectType instanceof check
- New regression test in tests/PHPStan/Rules/Comparison/data/bug-14429.php
@VincentLanglet
Copy link
Copy Markdown
Contributor

@staabm I don't understand why I cannot reproduce the nextras/orm failure

I tried this one https://github.com/nextras/orm/blob/274ef5b74d596f01528747deee26589706531c82/src/Relationships/HasOne.php#L269

Am I missing something to you ?

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 4, 2026

I can reproduce it in isolation with

<?php declare(strict_types = 1);

namespace Nextras\Orm\Relationships;

/**
 * @template E of IEntity
 * @implements IRelationshipContainer<E>
 */
abstract class HasOne implements IRelationshipContainer
{

	/** @var E|null */
	protected ?IEntity $parent = null;

	/** @var IRepository<E>|null */
	protected ?IRepository $targetRepository = null;

	/**
	 * @return IRepository<E>
	 */
	protected function getTargetRepository(): IRepository
	{
		if ($this->targetRepository === null) {
			/** @var IRepository<E> $targetRepository */
			$targetRepository = $this->getParentEntity()
				->getRepository()
				->getModel()
				->getRepository($this->metadataRelationship->repository);
			$this->targetRepository = $targetRepository;
		}

		return $this->targetRepository;
	}

	/**
	 * @return E
	 */
	protected function getParentEntity(): IEntity
	{
		return $this->parent ?? throw new \RuntimeException('Relationship is not attached to a parent entity.');
	}

}

interface IEntity {
	/**
	 * @return IRepository<IEntity>
	 */
	public function getRepository(): IRepository;
}

interface IProperty {}

interface IPropertyContainer extends IProperty {}

/**
 * @template E of IEntity
 */
interface IEntityAwareProperty extends IProperty {}

/**
 * @template E of IEntity
 * @extends IEntityAwareProperty<E>
 */
interface IRelationshipContainer extends IPropertyContainer, IEntityAwareProperty {}

interface IModel {
	/**
	 * @template E of IEntity
	 * @template T of IRepository<E>
	 * @param class-string<T> $className
	 * @return T
	 */
	public function getRepository(string $className): IRepository;
}

/**
 * @template E of IEntity
 */
interface IRepository {
	public function getModel(): IModel;
}

but was not yet able to reproduce it in a test

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix introduce a regression (see failing test), because

Bug14429\IRepository<Bug14429\IEntity>

is not a superTypeOf

Bug14429\IRepository<E of Bug14429\IEntity (class Bug14429\HasOne, parameter)>

anymore.

This is because

E of Bug14429\IEntity (class Bug14429\HasOne, parameter)

and

Bug14429\IEntity

are not equals anymore.

WDYT ?

The previous fix using `get_class($type) !== static::class` was too
strict - it prevented TemplateObjectType from being considered equal
to ObjectType, breaking invariant generic type variance checks.

Narrow the check to only reject GenericObjectType and EnumCaseObjectType
when the concrete classes differ, preserving template type compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The fix narrows the ObjectType::equals() check from the overly strict get_class($type) !== static::class to only reject EnumCaseObjectType and GenericObjectType when the concrete classes differ. This:

  1. Still fixes the original bug (Treat PHPDocTypes as certain:false being ignored phpstan#14429): ObjectType('ArrayObject') is no longer considered equal to GenericObjectType('ArrayObject', [StringType, StringType])
  2. Fixes the regression: TemplateObjectType (used for template types like E of IEntity) can still be compared with ObjectType in invariant variance checks, so IRepository<E of IEntity> remains a valid subtype of IRepository<IEntity>

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a solution to keep

ObjectType->(TemplateObjectType) false 

but IRepository remains a valid subtype of IRepository

…t variance for template types

Instead of narrowly rejecting only EnumCaseObjectType and GenericObjectType
in ObjectType::equals(), use a strict get_class() comparison for all
ObjectType subclasses. This makes equals() properly symmetric and prevents
any ObjectType subclass from being considered equal to a plain ObjectType.

To preserve the correct behavior where IRepository<E of IEntity> remains
a valid subtype of IRepository<IEntity>, add a template type bound check
in TemplateTypeVariance::isValidVariance() for invariant positions: when
the actual type argument ($b) is a TemplateType whose bound equals the
expected type argument ($a), treat them as equal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The background task (full WrongVariableNameInVarTagRuleTest suite) also passed successfully.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I start to think the isSuperTypeOf was wrong too cause if you look at

/**
	 * @return IRepository<E>
	 */
	protected function getTargetRepository(): IRepository
	{
		if ($this->targetRepository === null) {
			/** @var IRepository<E> $targetRepository */
			$targetRepository = $this->getParentEntity()
				->getRepository()
				->getModel()
				->getRepository($this->metadataRelationship->repository);

			\PHPStan\dumpType($targetRepository);
			$this->test($targetRepository);
			\PHPStan\dumpType($targetRepository);
			
			$this->targetRepository = $targetRepository;

			
		}

		return $this->targetRepository;
	}

	/** @param IRepository<IEntity> $i */
	public function test(IRepository $i): void {}

The IEntity is not covariant so test method might modify the IRepository and therefor the call $this->test($targetRepository); should be reported.

So maybe the issue is somewhere else, (in the VarTagTypeRuleHelper maybe ?)

We should have the same behavior with

/** @var IRepository<E> $targetRepository */

or

class Foo implements IEntity {}

/** @var IRepository<Foo> $targetRepository */

so no varTag.subType error

…leHelper

Revert the TemplateTypeVariance hack that treated template types as equal
to their bounds for invariant checks - this was conceptually wrong since
invariant means exact type equality.

Instead, fix VarTagTypeRuleHelper::checkType() to compare generic object
type arguments individually (covariantly) when both types are the same
generic class. This correctly allows @var tags like `@var IRepository<E>`
or `@var IRepository<Foo>` when the expression type is `IRepository<IEntity>`
and E/Foo are subtypes of IEntity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what I changed to address VincentLanglet's review:

  1. Reverted the TemplateTypeVariance hack (line 180-182) — the reviewer correctly pointed out that treating E of IEntity as equal to IEntity for invariant template checks is conceptually wrong. Invariant means exact type equality, and a template type is not the same as its bound.

  2. Fixed VarTagTypeRuleHelper::checkType() — when both the expression type and @var tag type are GenericObjectType of the same class, the type arguments are now compared individually via recursive checkType() calls. This uses covariant semantics (is the expression arg a supertype of the var tag arg?) rather than the strict invariant isSuperTypeOf check. This means both @var IRepository<E> and @var IRepository<Foo> (where Foo implements IEntity) are accepted when the expression type is IRepository<IEntity>, as the reviewer requested.

All tests pass (11723 tests, 78964 assertions) and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of modifying the VarTagTypeRule, maybe consider playing with the $acceptsContext boolean ?

…ypeVariance

Instead of special-casing GenericObjectType in VarTagTypeRuleHelper,
handle the template type equivalence at the type system level: in the
invariant variance check, when the other type ($b) is a TemplateType
whose bound equals $a, consider them equivalent.

This approach uses the existing $acceptsContext infrastructure direction
rather than adding rule-level workarounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what I changed to address VincentLanglet's review:

Removed the GenericObjectType special case from VarTagTypeRuleHelper::checkType() — as the reviewer suggested, the fix belongs at the type system level, not in the rule helper.

Added a template type bound check in TemplateTypeVariance::isValidVariance() (line 179-181): when the invariant $a->equals($b) check fails, we also check if $b is a TemplateType whose bound equals $a. This handles the case where IEntity is compared against E of IEntity — the template type's bound IS IEntity, so they're considered equivalent for invariant variance.

The check is intentionally one-directional ($b instanceof TemplateType only, not $a). This matters because:

  • VarTag case (IRepository<IEntity> vs IRepository<E of IEntity>): $a = IEntity, $b = E of IEntity → $b is TemplateType, bound check passes → no false error
  • Return type case (Collection<NewTKey, NewT> vs Collection<int|string, mixed>): $a = NewTKey, $b = int|string → $b is NOT TemplateType → error correctly preserved

All tests pass (11723 tests, 78964 assertions) and make phpstan reports no errors.

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.

3 participants