Some more bugs we discovered and this probably closes out the vast majority of our false positives. Thanks again for your quick followup.
A method-level @template T used as a type argument of a generic parameter is replaced by its bound
interface PBase {}
final class PA implements PBase {}
/** @template T of PBase */
final class PBox
{
/** @param T $value */
public function __construct(public PBase $value) {}
}
final class P1MethodTemplate
{
/**
* @template T of PBase
* @param PBox<T> $box
* @return T
*/
public function unwrap(PBox $box): PBase { return $box->value; }
}
(new P1MethodTemplate())->unwrap(new PBox(new PA()));
Actual:
P1MethodTemplate::unwrap(): Argument $box expects PBox<invariant PBase>, but PBox<PA> was given
The same happens when the consumer class carries unrelated class-level templates (P1b, the AdminRestApi shape) and when a class-level template sits inside an array parameter of the constructor (P8, @param array<array-key,PBox<T>> $items on @template T — EntityBatchResponse::__construct()).
Expected: T is inferred as PA from the argument, as PHPStan does; the call is accepted. Note for the item 7 follow-up: converting the repository constructors to method-level generics is blocked by this bug until it is fixed.
Static methods ignore the @extends binding of the called subclass
/** @template TEntity of PBase */
abstract class P2IdBase
{
/** @param non-empty-string|TEntity|P2IdBase<TEntity> $value */
public static function from(string|PBase|P2IdBase $value): static { return new static(); }
/** @param TEntity $entity */
public static function fromEntity(PBase $entity): static { return new static(); }
}
/** @extends P2IdBase<PA> */
final class P2AId extends P2IdBase {}
/** @extends P2IdBase<PBase> */
final class P2AnyId extends P2IdBase {}
P2AId::from(P2AId::from('x')); // (a)
P2AnyId::fromEntity(new PA()); // (b) accepted
P2AnyId::fromEntity(new PB()); // (b) rejected
Actual:
(a) P2AId::from(): Argument $value must be of type (non-empty-string | PBase | P2IdBase<PBase>), P2AId given
(b) P2AnyId::fromEntity(): Argument $entity (template TEntity = PA) must be of type PA, PB given
Two symptoms of the same gap: on a static call the class template is not taken from the called class's @extends. In (a) the bound is substituted and the union member P2IdBase<PBase> then rejects the class's own instance by invariance. In (b) the template is bound from the first call and kept for every later static call, although @extends P2IdBase<PBase> already fixes it.
Expected: TEntity = PA in (a) and TEntity = PBase in (b), as PHPStan resolves @extends; all three calls are accepted.
An interface's template bound is resolved in the namespace of the trait that supplies the method
namespace TypephpProbe;
/** @template TRoute of P3RouteInterface */
interface P3RouteInterface
{
/** @return TRoute */
public static function fromName(string $name): self;
}
/** @implements P3RouteInterface<self> */
enum P3Route: string implements P3RouteInterface
{
use \TypephpProbe\Sub\P3HasRoute;
case A = 'a';
}
// other file
namespace TypephpProbe\Sub;
trait P3HasRoute
{
public static function fromName(string $name): self { return self::from($name); }
}
P3Route::fromName('a');
Actual:
P3Route::fromName(): Return value must be of type P3RouteInterface, TypephpProbe\P3Route returned
The expected type is printed unqualified: the bound P3RouteInterface from the interface's docblock was resolved in the trait's namespace (TypephpProbe\Sub), where the name does not exist. The same enum passes with the static method written inline (P3b) or with the trait declared in the interface's namespace (P3c). Same family as item 4, on the interface-docblock-through-trait path.
Expected: the interface's docblock is resolved in the interface's namespace (or the @implements P3RouteInterface<self> binding is used); the return is accepted.
A Closure parameter read in the method body is replaced by a wrapper, so closure identity is lost
final class P5dStoredClosure
{
public Closure $factory;
/** @param Closure(?PA=): PBase $factory */
public function __construct(Closure $factory) { $this->factory = $factory; }
}
final class P5ClosureIdentity
{
/** @param Closure(?PA=): PBase $factory */
public function __construct(public Closure $factory) {}
}
$closure = static fn (?PA $a = null): PBase => new PA();
(new P5dStoredClosure($closure))->factory === $closure; // false — wrapper from src/Internal/Wrapper/CallableWrapper.php
(new P5ClosureIdentity($closure))->factory === $closure; // true
FunctionContractInjector injects a wrapCallable() dispatcher for the parameter local, so anything the body does with the parameter (store, forward, compare) sees a different Closure object; a promoted property keeps the original. The README does not mention the wrapping, and the two spellings behave differently. Possibly by design — filed as a behavior question: either document the identity change and make promotion consistent with body reads, or return the original closure after validation.
Expected: the stored closure is the object that was passed in, in both spellings.
Some more bugs we discovered and this probably closes out the vast majority of our false positives. Thanks again for your quick followup.
A method-level
@template Tused as a type argument of a generic parameter is replaced by its boundActual:
The same happens when the consumer class carries unrelated class-level templates (P1b, the
AdminRestApishape) and when a class-level template sits inside an array parameter of the constructor (P8,@param array<array-key,PBox<T>> $itemson@template T—EntityBatchResponse::__construct()).Expected:
Tis inferred asPAfrom the argument, as PHPStan does; the call is accepted. Note for the item 7 follow-up: converting the repository constructors to method-level generics is blocked by this bug until it is fixed.Static methods ignore the
@extendsbinding of the called subclassActual:
Two symptoms of the same gap: on a static call the class template is not taken from the called class's
@extends. In (a) the bound is substituted and the union memberP2IdBase<PBase>then rejects the class's own instance by invariance. In (b) the template is bound from the first call and kept for every later static call, although@extends P2IdBase<PBase>already fixes it.Expected:
TEntity = PAin (a) andTEntity = PBasein (b), as PHPStan resolves@extends; all three calls are accepted.An interface's template bound is resolved in the namespace of the trait that supplies the method
Actual:
The expected type is printed unqualified: the bound
P3RouteInterfacefrom the interface's docblock was resolved in the trait's namespace (TypephpProbe\Sub), where the name does not exist. The same enum passes with the static method written inline (P3b) or with the trait declared in the interface's namespace (P3c). Same family as item 4, on the interface-docblock-through-trait path.Expected: the interface's docblock is resolved in the interface's namespace (or the
@implements P3RouteInterface<self>binding is used); the return is accepted.A
Closureparameter read in the method body is replaced by a wrapper, so closure identity is lostFunctionContractInjectorinjects awrapCallable()dispatcher for the parameter local, so anything the body does with the parameter (store, forward, compare) sees a differentClosureobject; a promoted property keeps the original. The README does not mention the wrapping, and the two spellings behave differently. Possibly by design — filed as a behavior question: either document the identity change and make promotion consistent with body reads, or return the original closure after validation.Expected: the stored closure is the object that was passed in, in both spellings.