Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1013c9f
first pass
shmax Apr 1, 2026
3a7cc4f
enforce required generic params
shmax Apr 1, 2026
589aa11
more tests
shmax Apr 1, 2026
5963972
tests for invalud usage
shmax Apr 1, 2026
8759bef
fix regression
shmax Apr 1, 2026
cd30ab8
add more scenarios to test fixture
shmax Apr 1, 2026
3cdf8c3
remove empty patch
shmax Apr 1, 2026
6eaee2c
coalesce to empty array
shmax Apr 1, 2026
a2d234f
lint
shmax Apr 1, 2026
c70b778
fix use function ordering
shmax Apr 1, 2026
ff96cba
fix generic alias resolution: skip alias path when name resolves to a…
shmax Apr 1, 2026
a7d5c46
remove resolveWithDefaults: bare generic alias usage restores old Tem…
shmax Apr 1, 2026
af6079f
fix phpstan self-analysis errors: ignore property.notFound, add null …
shmax Apr 1, 2026
a0f9af0
remove ignore
shmax Apr 1, 2026
6778ace
add property.notFound baseline entry for PHP < 8.0 (phpdoc-parser lac…
shmax Apr 1, 2026
8c95f1f
move property.notFound baseline entry to universal phpstan-baseline.n…
shmax Apr 1, 2026
7f5b84d
fix generic alias bare-usage resolution and data-provider parameter s…
shmax Apr 2, 2026
1483f56
update baseline after rebase onto 2.1.x
shmax Apr 2, 2026
24e1486
add temp patches
shmax Apr 2, 2026
5080e33
update lock hash
shmax Apr 2, 2026
c1c8d4d
use LateResolvableType
shmax Apr 2, 2026
a1f0535
remove
shmax Apr 2, 2026
b83587c
lint
shmax Apr 2, 2026
4b16984
fix array vs list error
shmax Apr 2, 2026
928ab12
remove dead code
shmax Apr 2, 2026
5df2e4a
Merge branch '2.1.x' into generics-on-phpstan-types
shmax Apr 2, 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
Binary file removed .idea/icon.png
Binary file not shown.
12 changes: 8 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,14 @@
"patches/DependencyChecker.patch",
"patches/Resolver.patch"
],
"symfony/console": [
"patches/OutputFormatter.patch"
]
}
"symfony/console": [
"patches/OutputFormatter.patch"
],
"phpstan/phpdoc-parser": [
"patches/TypeAliasTagValueNode.patch",
"patches/PhpDocParser.patch"
]
}
},
"autoload": {
"psr-4": {
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions patches/PhpDocParser.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--- src/Parser/PhpDocParser.php
+++ src/Parser/PhpDocParser.php
@@ -1067,6 +1067,21 @@
$alias = $tokens->currentTokenValue();
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);

+ $templateTypes = [];
+ if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) {
+ do {
+ $startLine = $tokens->currentTokenLine();
+ $startIndex = $tokens->currentTokenIndex();
+ $templateTypes[] = $this->enrichWithAttributes(
+ $tokens,
+ $this->typeParser->parseTemplateTagValue($tokens),
+ $startLine,
+ $startIndex,
+ );
+ } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA));
+ $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET);
+ }
+
// support phan-type/psalm-type syntax
$tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL);

@@ -1087,12 +1102,13 @@
}
}

- return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type);
+ return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type, $templateTypes);
} catch (ParserException $e) {
$this->parseOptionalDescription($tokens, false);
return new Ast\PhpDoc\TypeAliasTagValueNode(
$alias,
$this->enrichWithAttributes($tokens, new Ast\Type\InvalidTypeNode($e), $startLine, $startIndex),
+ $templateTypes,
);
}
}
47 changes: 47 additions & 0 deletions patches/TypeAliasTagValueNode.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
--- src/Ast/PhpDoc/TypeAliasTagValueNode.php
+++ src/Ast/PhpDoc/TypeAliasTagValueNode.php
@@ -4,6 +4,7 @@

use PHPStan\PhpDocParser\Ast\NodeAttributes;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
+use function implode;
use function trim;

class TypeAliasTagValueNode implements PhpDocTagValueNode
@@ -15,15 +16,25 @@

public TypeNode $type;

- public function __construct(string $alias, TypeNode $type)
+ /** @var TemplateTagValueNode[] */
+ public array $templateTypes;
+
+ /**
+ * @param TemplateTagValueNode[] $templateTypes
+ */
+ public function __construct(string $alias, TypeNode $type, array $templateTypes = [])
{
$this->alias = $alias;
$this->type = $type;
+ $this->templateTypes = $templateTypes;
}

public function __toString(): string
{
- return trim("{$this->alias} {$this->type}");
+ $templateTypes = $this->templateTypes !== []
+ ? '<' . implode(', ', $this->templateTypes) . '>'
+ : '';
+ return trim("{$this->alias}{$templateTypes} {$this->type}");
}

/**
@@ -31,7 +42,7 @@
*/
public static function __set_state(array $properties): self
{
- $instance = new self($properties['alias'], $properties['type']);
+ $instance = new self($properties['alias'], $properties['type'], $properties['templateTypes'] ?? []);
if (isset($properties['attributes'])) {
foreach ($properties['attributes'] as $key => $value) {
$instance->setAttribute($key, $value);
2 changes: 1 addition & 1 deletion src/PhpDoc/PhpDocNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop
foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) {
$alias = $typeAliasTagValue->alias;
$typeNode = $typeAliasTagValue->type;
$resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope);
$resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes);
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/PhpDoc/Tag/TypeAliasTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\PhpDoc\Tag;

use PHPStan\Analyser\NameScope;
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\TypeAlias;

Expand All @@ -12,10 +13,14 @@
final class TypeAliasTag
{

/**
* @param TemplateTagValueNode[] $templateTagValueNodes
*/
public function __construct(
private string $aliasName,
private TypeNode $typeNode,
private NameScope $nameScope,
private array $templateTagValueNodes = [],
)
{
}
Expand All @@ -30,6 +35,8 @@ public function getTypeAlias(): TypeAlias
return new TypeAlias(
$this->typeNode,
$this->nameScope,
$this->templateTagValueNodes,
$this->aliasName,
);
}

Expand Down
60 changes: 60 additions & 0 deletions src/PhpDoc/TypeNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
use PHPStan\Type\StringType;
use PHPStan\Type\ThisType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeAlias;
use PHPStan\Type\TypeAliasResolver;
use PHPStan\Type\TypeAliasResolverProvider;
use PHPStan\Type\TypeCombinator;
Expand All @@ -110,6 +111,7 @@
use PHPStan\Type\ValueOfType;
use PHPStan\Type\VoidType;
use Traversable;
use function array_filter;
use function array_key_exists;
use function array_map;
use function array_values;
Expand Down Expand Up @@ -828,6 +830,29 @@ static function (string $variance): TemplateTypeVariance {
return new ErrorType();
}

// Check for a generic type alias (e.g. MyList<string>) before falling through to
// class-based generic resolution, but only when the name is not itself a resolvable
// class — in that case the class-based path must win to preserve backward compatibility
// (a type alias like BelongsTo<T, U> = \Eloquent\BelongsTo<T, U> should still produce
// the same GenericObjectType that class-based resolution would have given).
$resolvedGenericName = $nameScope->resolveStringName($typeNode->type->name);
$genericTypeAlias = !$this->getReflectionProvider()->hasClass($resolvedGenericName)
? $this->findGenericTypeAlias($typeNode->type->name, $nameScope)
: null;
if ($genericTypeAlias !== null) {
$templateNodes = $genericTypeAlias->getTemplateTagValueNodes();
$totalParams = count($templateNodes);
$requiredParams = count(array_filter($templateNodes, static fn ($tvn) => $tvn->default === null));
$providedArgs = count($genericTypes);

if ($providedArgs > $totalParams || $providedArgs < $requiredParams) {
return new ErrorType();
}

$appType = $genericTypeAlias->createApplicationType($this, $genericTypes);
return $appType->isResolvable() ? $appType->resolve() : $appType;
}

$mainType = $this->resolveIdentifierTypeNode($typeNode->type, $nameScope);
$mainTypeObjectClassNames = $mainType->getObjectClassNames();
if (count($mainTypeObjectClassNames) > 1) {
Expand Down Expand Up @@ -1360,4 +1385,39 @@ private function getTypeAliasResolver(): TypeAliasResolver
return $this->typeAliasResolverProvider->getTypeAliasResolver();
}

/**
* Returns the TypeAlias for $name if it is a generic (parameterised) type alias
* visible in the current $nameScope, or null otherwise.
*/
private function findGenericTypeAlias(string $name, NameScope $nameScope): ?TypeAlias
{
if ($nameScope->shouldBypassTypeAliases()) {
return null;
}

// Fast path: if the name isn't registered as a type alias in this scope, skip the
// more expensive ClassReflection::getTypeAliases() call. This also prevents a circular
// NameScope-building issue: getTypeAliases() can trigger FileTypeMapper::getResolvedPhpDoc()
// which calls getNameScope() — if we are already inside getNameScope() for this class,
// that throws NameScopeAlreadyBeingCreatedException, causing the class's ResolvedPhpDocBlock
// to be poisoned with an empty block and all its type aliases to be lost.
// UsefulTypeAliasResolver uses the same guard.
if (!$nameScope->hasTypeAlias($name)) {
return null;
}

$className = $nameScope->getClassNameForTypeAlias();
if ($className === null || !$this->getReflectionProvider()->hasClass($className)) {
return null;
}

$typeAliases = $this->getReflectionProvider()->getClass($className)->getTypeAliases();
if (!array_key_exists($name, $typeAliases)) {
return null;
}

$typeAlias = $typeAliases[$name];
return $typeAlias->isGeneric() ? $typeAlias : null;
}

}
16 changes: 16 additions & 0 deletions src/Rules/Classes/LocalTypeAliasesCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,22 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($resolvedType) as [$innerAliasName, $missingParams]) {
if ($innerAliasName === $aliasName) {
continue; // skip self-referential alias bodies (circular aliases are already reported separately)
}
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has type alias %s with generic type alias %s but does not specify its types: %s',
$reflection->getClassTypeDescription(),
$reflection->getDisplayName(),
$aliasName,
$innerAliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($resolvedType) as $callableType) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has type alias %s with no signature specified for %s.',
Expand Down
13 changes: 13 additions & 0 deletions src/Rules/Classes/MethodTagCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,19 @@ private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classR
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) {
$errors[] = RuleErrorBuilder::message(sprintf(
'PHPDoc tag @method for method %s::%s() %s contains generic type alias %s but does not specify its types: %s',
$classReflection->getDisplayName(),
$methodName,
$description,
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) {
$iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly());
$errors[] = RuleErrorBuilder::message(sprintf(
Expand Down
10 changes: 10 additions & 0 deletions src/Rules/Classes/MixinCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ public function checkInTraitDefinitionContext(ClassReflection $classReflection):
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) {
$errors[] = RuleErrorBuilder::message(sprintf(
'PHPDoc tag @mixin contains generic type alias %s but does not specify its types: %s',
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s has PHPDoc tag @mixin with no signature specified for %s.',
Expand Down
13 changes: 13 additions & 0 deletions src/Rules/Classes/PropertyTagCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $clas
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) {
$errors[] = RuleErrorBuilder::message(sprintf(
'PHPDoc tag %s for property %s::$%s contains generic type alias %s but does not specify its types: %s',
$tagName,
$classReflection->getDisplayName(),
$propertyName,
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) {
$iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly());
$errors[] = RuleErrorBuilder::message(sprintf(
Expand Down
12 changes: 12 additions & 0 deletions src/Rules/Constants/MissingClassConstantTypehintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ private function processSingleConstant(ClassReflection $classReflection, string
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($constantType) as [$aliasName, $missingParams]) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Constant %s::%s with generic type alias %s does not specify its types: %s',
$constantReflection->getDeclaringClass()->getDisplayName(),
$constantName,
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($constantType) as $callableType) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Constant %s::%s type has no signature specified for %s.',
Expand Down
12 changes: 12 additions & 0 deletions src/Rules/Functions/MissingFunctionParameterTypehintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ private function checkFunctionParameter(FunctionReflection $functionReflection,
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Function %s() has %s with generic type alias %s but does not specify its types: %s',
$functionReflection->getName(),
$parameterMessage,
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Function %s() has %s with no signature specified for %s.',
Expand Down
11 changes: 11 additions & 0 deletions src/Rules/Functions/MissingFunctionReturnTypehintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ public function processNode(Node $node, Scope $scope): array
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($returnType) as [$aliasName, $missingParams]) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Function %s() return type with generic type alias %s does not specify its types: %s',
$functionReflection->getName(),
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Function %s() return type has no signature specified for %s.',
Expand Down
13 changes: 13 additions & 0 deletions src/Rules/Methods/MissingMethodParameterTypehintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ private function checkMethodParameter(MethodReflection $methodReflection, string
->build();
}

foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Method %s::%s() has %s with generic type alias %s but does not specify its types: %s',
$methodReflection->getDeclaringClass()->getDisplayName(),
$methodReflection->getName(),
$parameterMessage,
$aliasName,
$missingParams,
))
->identifier('missingType.generics')
->build();
}

foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) {
$messages[] = RuleErrorBuilder::message(sprintf(
'Method %s::%s() has %s with no signature specified for %s.',
Expand Down
Loading
Loading