Pair<T, *> (and Laravel packages write Relation<T, *, *>)
when only some type arguments matter. The weaker honest reading is to treat
* as "unspecified" so T still binds from Box<T>. Full star-projection
semantics are phpdoc_advanced_phpstan_star_projection.
Psalm 7 rejects * as InvalidDocblock, discards the whole param tag, and
leaves T unbound so a Box<T> return degrades to Box<never> — callers then
see dead-code diagnostics (vimeo/psalm#11931).
Source lead: vimeo/psalm#11931.| Analyzer | Version | Result | Diagnostics |
|---|---|---|---|
| phan | 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9 | Unrecognized | regressions_star_projection_union_template.php:66: Saw possibly unextractable annotation for a fragment of comment '* @param Box<T>|Pair<T, *> $subject': after Box<T>|Pair, did not see an element name (will guess based on comment order) [PhanUnextractableAnnotationElementName] regressions_star_projection_union_template.php:66: Saw a token Phan may have failed to parse after '* @param Box<T>|Pair<T, *> $subject': after Box<T>|Pair, saw '<' [PhanUnextractableAnnotationSuffix] regressions_star_projection_union_template.php:69: Template type T not declared in parameters of function/method make() (or Phan can't extract template types for this use case) [PhanTemplateTypeNotDeclaredInFunctionParams]
Notes: Cannot parse `*` in Pair<T, *>, but T still binds from the Box arm and the caller is not marked dead. |
| phpstan | 2.2.15PHPStan - PHP Static Analysis Tool 2.2.15 | Enforced | No diagnostics reported.
|
| psalm | 6.18.0Psalm 6.18.0@536dd6236b39a5115385b0307b42d6d1ad431a02 next: 7.0.0-beta22 |
Unrecognized (pzoom≠) | regressions_star_projection_union_template.php:66: Invalid type 'Conformance\Tests\RegressionsStarProjectionUnionTemplate\*' in docblock for Conformance\Tests\RegressionsStarProjectionUnionTemplate\Factory::make [InvalidDocblock] regressions_star_projection_union_template.php:100: All possible types for this argument were invalidated - This may be dead code [NoValue] pzoom (Psalm port)No diagnostics from pzoom.
Expectation diffLine 100: Expected valid value to be accepted, got ["All possible types for this argument were invalidated - This may be dead code [NoValue]"] Notes: InvalidDocblock on `*` discards the `@param`, T never binds, and `@return Box<T>` degrades to Box<never>. Factory::make($box)->get() is then NoValue. The echo after that stays quiet in this shape; the cascade is the never return, not a later UnevaluatedCode. vimeo/psalm#11931. |
| mago | 1.50.0mago 1.50.0 | Enforced | No diagnostics reported.
|
| mir | 0.78.0mir 0.78.0 | Enforced | No diagnostics reported.
|
| phpantom | 0.10.0phpantom_lsp 0.10.0 | Enforced | No diagnostics reported.
|
| intelephense | 1.18.5intelephense 1.18.5 | Enforced | No diagnostics reported.
|
| phpactor | 2026.06.23.0Phpactor 2026.06.23.0 | Enforced | No diagnostics reported.
|
| phpy | 1.0.19274phpy 1.0.19274 | Enforced | No diagnostics reported.
|
| qodana | 262.9437.196Qodana 262.9437.196 | Enforced | No diagnostics reported.
Notes: Silent on Pair<T, *>: T still binds from the Box arm and the caller is not marked dead. |
| noverify | 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 | Enforced ⚠ 10 false positives | regressions_star_projection_union_template.php:28: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TValue does not exist [undefinedClass] regressions_star_projection_union_template.php:31: param $value miss matched with phpdoc type <<TValue>> [funcParamTypeMissMatch] regressions_star_projection_union_template.php:36: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TValue does not exist [undefinedClass] regressions_star_projection_union_template.php:51: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TFirst does not exist [undefinedClass] regressions_star_projection_union_template.php:52: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TSecond does not exist [undefinedClass] regressions_star_projection_union_template.php:55: param $first miss matched with phpdoc type <<TFirst>> [funcParamTypeMissMatch] regressions_star_projection_union_template.php:56: param $second miss matched with phpdoc type <<TSecond>> [funcParamTypeMissMatch] regressions_star_projection_union_template.php:84: param $subject miss matched with phpdoc type <<Box<T>>> [funcParamTypeMissMatch] regressions_star_projection_union_template.php:97: param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch] regressions_star_projection_union_template.php:107: param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]
Expectation diffLine 28: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TValue does not exist [undefinedClass]"] Line 31: Unexpected errors ["param $value miss matched with phpdoc type <<TValue>> [funcParamTypeMissMatch]"] Line 36: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TValue does not exist [undefinedClass]"] Line 51: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TFirst does not exist [undefinedClass]"] Line 52: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TSecond does not exist [undefinedClass]"] Line 55: Unexpected errors ["param $first miss matched with phpdoc type <<TFirst>> [funcParamTypeMissMatch]"] Line 56: Unexpected errors ["param $second miss matched with phpdoc type <<TSecond>> [funcParamTypeMissMatch]"] Line 84: Unexpected errors ["param $subject miss matched with phpdoc type <<Box<T>>> [funcParamTypeMissMatch]"] Line 97: Unexpected errors ["param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]"] Line 107: Unexpected errors ["param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]"] |
| steins | 0.1.8steins 0.1.8 (2026-09-21 revision 76306a9) | Enforced | No diagnostics reported.
|
| phpstan-strict | 2.2.15PHPStan - PHP Static Analysis Tool 2.2.15 | Enforced | No diagnostics reported.
|
| psalm-next | 7.0.0-beta22Psalm 7.0.0-beta22@de27e5724ee5997a1e3baee4d394ca6f4e46eba0 | Unrecognized | regressions_star_projection_union_template.php:66: Invalid type 'Conformance\Tests\RegressionsStarProjectionUnionTemplate\*' in docblock for Conformance\Tests\RegressionsStarProjectionUnionTemplate\Factory::make [InvalidDocblock] regressions_star_projection_union_template.php:100: All possible types for this argument were invalidated - This may be dead code [NoValue]
Expectation diffLine 100: Expected valid value to be accepted, got ["All possible types for this argument were invalidated - This may be dead code [NoValue]"] Notes: Same as Psalm 6/7: InvalidDocblock on `*` leaves T unbound, get() is never, takesInt is NoValue. vimeo/psalm#11931. |
<?php
declare(strict_types=1);
namespace Conformance\Tests\RegressionsStarProjectionUnionTemplate;
/**
* A star projection in one union arm must not unbind T in the others.
*
* PHPStan writes `Pair<T, *>` (and Laravel packages write `Relation<T, *, *>`)
* when only some type arguments matter. The weaker honest reading is to treat
* `*` as "unspecified" so T still binds from `Box<T>`. Full star-projection
* semantics are `phpdoc_advanced_phpstan_star_projection`.
*
* Psalm 7 rejects `*` as `InvalidDocblock`, discards the whole param tag, and
* leaves T unbound so a `Box<T>` return degrades to `Box<never>` — callers then
* see dead-code diagnostics (vimeo/psalm#11931).
*
* Source lead: vimeo/psalm#11931.
*/
/**
* @template TValue
*/
final class Box
{
/**
* @param TValue $value
*/
public function __construct(
public mixed $value,
) {
}
/**
* @return TValue
*/
public function get(): mixed
{
return $this->value;
}
}
/**
* @template TFirst
* @template TSecond
*/
final class Pair
{
/**
* @param TFirst $first
* @param TSecond $second
*/
public function __construct(
public mixed $first,
public mixed $second,
) {
}
}
final class Factory
{
/**
* @template T
*
* @param Box<T>|Pair<T, *> $subject
* @return Box<T>
*/
public static function make(Box|Pair $subject): Box // T: Pair<T, *>
{
if ($subject instanceof Box) {
return $subject; // V
}
throw new \InvalidArgumentException('not a Box');
}
/**
* @template T
*
* @param Box<T> $subject
* @return Box<T>
*/
public static function makeBoxOnly(Box $subject): Box
{
return $subject; // V
}
}
function takesInt(int $value): void
{
}
/**
* @param Box<int> $box
*/
function consumeStar(Box $box): void
{
$result = Factory::make($box); // V: T binds from Box<int> regardless of the Pair arm
takesInt($result->get()); // V
echo 'reachable after star'; // Q: Box<never> must not mark the rest of the caller dead
}
/**
* @param Box<int> $box
*/
function consumePlain(Box $box): void
{
$result = Factory::makeBoxOnly($box); // V: the same call without `*` in the union
takesInt($result->get()); // V
echo 'reachable without star'; // V
}
consumeStar(new Box(1)); // V
consumePlain(new Box(1)); // V