Repository navigation
[Bug]: Class template name read as a class when the property's declaring class is outside include #104
Description
Summary
When a property is typed with a class-level template (@var TEntity) and the class that declares it is not in include, an assignment made from an included file is checked against a class literally named TEntity:
Property App\\Fields\\TextInputField::$entity must be of type TEntity, App\\Entities\\Project given
TEntity is an unbound @template (no of), so any value should pass. With the declaring class included, the same assignment passes.
Reproduction (5 files, multi-file so no playground link: the bug needs a split include)
typephp.php
<?php
return ['include' => ['src/Commands/**'], 'array_validation' => 'full'];src/Fields/FieldBase.php
<?php
declare(strict_types=1);
namespace App\\Fields;
/**
* @template TEntity
*/
abstract class FieldBase
{
/**
* @var TEntity
*/
public $entity;
/**
* @var int<0, max>
*/
public int $count = 0;
}src/Fields/InputField.php
<?php
declare(strict_types=1);
namespace App\\Fields;
/**
* @template TEntity
* @extends FieldBase<TEntity>
*/
abstract class InputField extends FieldBase
{
}src/Fields/TextInputField.php
<?php
declare(strict_types=1);
namespace App\\Fields;
class TextInputField extends InputField
{
}src/Entities/Project.php
<?php
declare(strict_types=1);
namespace App\\Entities;
final class Project
{
}src/Commands/FieldBuilder.php (the only included file)
<?php
declare(strict_types=1);
namespace App\\Commands;
use App\\Fields\\InputField;
final class FieldBuilder
{
public function build(string $className, object $entity): InputField
{
$field = new $className();
$field->entity = $entity;
return $field;
}
public function probe(InputField $field): void
{
$field->count = -1;
}
}run.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use App\\Commands\\FieldBuilder;
use App\\Entities\\Project;
use App\\Fields\\TextInputField;
$builder = new FieldBuilder();
try {
$builder->probe(new TextInputField());
echo "probe: NOT caught\\n";
} catch (\\Throwable $e) {
echo "probe: CAUGHT ", $e->getMessage(), "\\n";
}
try {
$builder->build(TextInputField::class, new Project());
echo "entity: assigned OK\\n";
} catch (\\Throwable $e) {
echo "entity: ", $e->getMessage(), "\\n";
}composer.json: PSR-4 App\\\\ => src/, require typephp/typephp.
Actual
probe: CAUGHT Property App\\Fields\\TextInputField::$count must be >= 0, -1 given
entity: Property App\\Fields\\TextInputField::$entity must be of type TEntity, App\\Entities\\Project given
Expected
probe: CAUGHT Property App\\Fields\\TextInputField::$count must be >= 0, -1 given
entity: assigned OK
Controls
include => ['src/**'](declaring class included too):entity: assigned OK. Only the split include fails.- The probe line shows property checks are active on the same object in both runs.
Versions
v0.11.1 and dev-main @ 8c3b01d (2026-10-05), PHP 8.3, opcache.enable_cli=0.
Context
Found in a large codebase where the framework's base field classes live outside the checked paths and module code assigns $field->entity: about 30 violations per module run, all this one shape. I guess the property type is resolved from the docblock without the declaring class's template list when that class was not parsed, so the template name falls through to class resolution. Treating an unresolvable template as its bound (or mixed when unbound) would match PHPStan.
Activity