PHPantom version
phpantom_lsp 0.8.0 (also reproduces on main @ 669b0e1)
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Neovim
Bug description
A class-level generic return type (@return T / @psalm-return ?T) that should be resolved through an @extends Parent<Concrete> binding is dropped — the call resolves to the bare native hint instead — whenever the inherited method's native return type is a nullable union like object|null.
The same setup with a non-nullable native hint (object) works correctly, so the trigger is specifically the nullability of the native return type, not the generic machinery itself (which is otherwise fine).
This is exactly Doctrine's ServiceEntityRepository<T>::find(): ?T shape (@return object|null + @psalm-return ?T, native hint object|null): $repo->find() resolves to object|null instead of Entity|null, so the result is effectively untyped — no completion on it, and find-references on common methods called on it (getTitle(), getId(), …) become name-based and over-broad.
Expected: $repo->find() resolves to Entity|null.
Actual: $repo->find() resolves to object|null (the generic ?T is discarded).
Steps to reproduce
Single file is enough (any composer.json PSR-4 autoload, or even a single file in the workspace root). No vendor/ needed.
<?php
/** @template T of object */
class Repo
{
/**
* @return object|null
* @psalm-return ?T
*/
public function find(): object|null {}
}
class Entity
{
public function name(): string {}
}
/** @extends Repo<Entity> */
class EntityRepo extends Repo {}
function test(EntityRepo $repo): void
{
$e = $repo->find();
// Hover on $e -> expected: Entity|null | actual: object|null
$e->name(); // `name` is not completed/resolved, because $e is `object|null`
}
- Open the file in your editor.
- Hover on
$e (the result of $repo->find()).
- Expected:
Entity|null. Actual: object|null.
- Completion on
$e-> does not offer Entity's members.
Contrast — the non-nullable version resolves correctly, which pinpoints the trigger:
/** @template T */
class Box
{
/** @return T */
public function get(): object {} // native hint `object` (non-nullable) -> works
}
/** @extends Box<Entity> */
class EntityBox extends Box {}
// $box->get() -> correctly resolves to `Entity`
The bound (T of object vs T) and inheritance depth don't matter — single-level @extends Repo<Entity> with a nullable native hint is enough. Multi-level (Repo -> ServiceEntityRepository<T> -> EntityRepository<T>, i.e. real Doctrine) is also affected.
Error output or panic trace
No crash and no error output — purely incorrect type inference.
.phpantom.toml
default / no config file
Additional context
PR with fix: #152
Root cause (traced in source): src/docblock/tags.rs::should_override_type_typed decides native-vs-docblock return type using PhpType::unwrap_nullable(), which only strips the ?Foo (Nullable) representation — not the Foo|null (Union with a null member) representation. So a nullable-union native like object|null reaches the "union" branch with its null member still attached. Since both object and null are in is_scalar_name, that branch (members.iter().any(|m| !m.is_scalar())) judges the whole type unrefinable, returns false, and the generic docblock return is discarded —leaving the bare native object|null. (The function's own comment already states the intent: Foo|null -> Foo.)
Instrumented to confirm: native="object" -> override=true, but native="object|null" -> override=false for the same ?T docblock.
Fix: use non_null_type() (which strips null from both the Nullable and the Union representations) instead of unwrap_nullable() when computing the inner types in should_override_type_typed.
PHPantom version
phpantom_lsp 0.8.0 (also reproduces on main @ 669b0e1)
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Neovim
Bug description
A class-level generic return type (
@return T/@psalm-return ?T) that should be resolved through an@extends Parent<Concrete>binding is dropped — the call resolves to the bare native hint instead — whenever the inherited method's native return type is a nullable union likeobject|null.The same setup with a non-nullable native hint (
object) works correctly, so the trigger is specifically the nullability of the native return type, not the generic machinery itself (which is otherwise fine).This is exactly Doctrine's
ServiceEntityRepository<T>::find(): ?Tshape (@return object|null+@psalm-return ?T, native hintobject|null):$repo->find()resolves toobject|nullinstead ofEntity|null, so the result is effectively untyped — no completion on it, and find-references on common methods called on it (getTitle(),getId(), …) become name-based and over-broad.Expected:
$repo->find()resolves toEntity|null.Actual:
$repo->find()resolves toobject|null(the generic?Tis discarded).Steps to reproduce
Single file is enough (any
composer.jsonPSR-4 autoload, or even a single file in the workspace root). Novendor/needed.$e(the result of$repo->find()).Entity|null. Actual:object|null.$e->does not offerEntity's members.Contrast — the non-nullable version resolves correctly, which pinpoints the trigger:
The bound (
T of objectvsT) and inheritance depth don't matter — single-level@extends Repo<Entity>with a nullable native hint is enough. Multi-level (Repo -> ServiceEntityRepository<T> -> EntityRepository<T>, i.e. real Doctrine) is also affected.Error output or panic trace
No crash and no error output — purely incorrect type inference.
.phpantom.toml
default / no config file
Additional context
PR with fix: #152
Root cause (traced in source):
src/docblock/tags.rs::should_override_type_typeddecides native-vs-docblock return type usingPhpType::unwrap_nullable(), which only strips the?Foo(Nullable) representation — not theFoo|null(Unionwith anullmember) representation. So a nullable-union native likeobject|nullreaches the "union" branch with itsnullmember still attached. Since bothobjectandnullare inis_scalar_name, that branch (members.iter().any(|m| !m.is_scalar())) judges the whole type unrefinable, returnsfalse, and the generic docblock return is discarded —leaving the bare nativeobject|null. (The function's own comment already states the intent:Foo|null -> Foo.)Instrumented to confirm:
native="object" -> override=true, butnative="object|null" -> override=falsefor the same?Tdocblock.Fix: use
non_null_type()(which stripsnullfrom both theNullableand theUnionrepresentations) instead ofunwrap_nullable()when computing the inner types inshould_override_type_typed.