From 4d58bb2a2c0541d5e8fa18761bf09cb772d547e1 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 15 Jun 2026 12:28:22 +0200 Subject: [PATCH] Fix doctrine.columnType false positive for single table inheritance In single table inheritance, Doctrine's SchemaTool forces every child-entity column to be nullable in the database, because the column is shared with the rest of the hierarchy that does not set the field (see Doctrine\ORM\Tools\SchemaTool::gatherColumn). The property itself, however, is always set for that child, so it may legitimately stay non-nullable. The rule now detects this STI-forced nullability and keeps requiring the nullable column on the database side while no longer demanding the property accept null. Genuine type mismatches in child entities are still reported. Co-Authored-By: Claude Code --- src/Rules/Doctrine/ORM/EntityColumnRule.php | 15 +++- .../Doctrine/ORM/EntityColumnRuleTest.php | 20 ++++++ .../ORM/data/single-table-inheritance.php | 71 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/Rules/Doctrine/ORM/data/single-table-inheritance.php diff --git a/src/Rules/Doctrine/ORM/EntityColumnRule.php b/src/Rules/Doctrine/ORM/EntityColumnRule.php index 75aa1ae6..c3d454ca 100644 --- a/src/Rules/Doctrine/ORM/EntityColumnRule.php +++ b/src/Rules/Doctrine/ORM/EntityColumnRule.php @@ -192,10 +192,21 @@ public function processNode(Node $node, Scope $scope): array } $nullable = isset($fieldMapping['nullable']) ? $fieldMapping['nullable'] === true : false; - if ($nullable) { - $writableToPropertyType = TypeCombinator::addNull($writableToPropertyType); + + // In single table inheritance, the database columns of child entities are always nullable, + // because the column is shared with the rest of the hierarchy that does not have the field set + // (see Doctrine\ORM\Tools\SchemaTool::gatherColumn). The property itself, however, may stay + // non-nullable, so the forced database nullability must not be required on the property side. + $nullabilityForcedBySingleTableInheritance = $metadata->isInheritanceTypeSingleTable() + && $metadata->parentClasses !== [] + && !isset($fieldMapping['inherited']); + + if ($nullable || $nullabilityForcedBySingleTableInheritance) { $writableToDatabaseType = TypeCombinator::addNull($writableToDatabaseType); } + if ($nullable && !$nullabilityForcedBySingleTableInheritance) { + $writableToPropertyType = TypeCombinator::addNull($writableToPropertyType); + } $phpDocType = $node->getPhpDocType(); $nativeType = $node->getNativeType() ?? new MixedType(); diff --git a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php index a29a1d57..c6d687cc 100644 --- a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php @@ -548,6 +548,26 @@ public function testBugSingleEnum(?string $objectManagerLoader): void $this->analyse([__DIR__ . '/data/bug-single-enum.php'], []); } + /** + * @dataProvider dataObjectManagerLoader + */ + public function testSingleTableInheritance(?string $objectManagerLoader): void + { + $this->allowNullablePropertyForRequiredField = false; + $this->objectManagerLoader = $objectManagerLoader; + + $this->analyse([__DIR__ . '/data/single-table-inheritance.php'], [ + [ + 'Property PHPStan\Rules\Doctrine\ORM\SingleTableInheritance\ChildEntity::$childBrokenColumn type mapping mismatch: database can contain string but property expects int.', + 69, + ], + [ + 'Property PHPStan\Rules\Doctrine\ORM\SingleTableInheritance\ChildEntity::$childBrokenColumn type mapping mismatch: property can contain int but database expects string|null.', + 69, + ], + ]); + } + /** * @dataProvider dataObjectManagerLoader */ diff --git a/tests/Rules/Doctrine/ORM/data/single-table-inheritance.php b/tests/Rules/Doctrine/ORM/data/single-table-inheritance.php new file mode 100644 index 00000000..0a6af8c1 --- /dev/null +++ b/tests/Rules/Doctrine/ORM/data/single-table-inheritance.php @@ -0,0 +1,71 @@ +