From acf44c2c23f92bbddf2a86123d365cc78bceea28 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 12 Mar 2026 14:03:50 +0100 Subject: [PATCH 1/3] array_column should not extract non-accessible properties from objects PHP's array_column respects calling scope visibility. This change: - Checks scope->canReadProperty() before including a property type - When both __isset and __get are defined, treats inaccessible properties as maybe-accessible (returns generic array) - For non-final classes, accounts for child classes that could override visibility: protected keeps its type (invariant in PHP), private becomes mixed (child can redeclare with any type) - For final classes, inaccessible properties return empty array - Handles NeverType in index position by falling back to integer keys Fixes https://github.com/phpstan/phpstan/issues/13573 --- src/Type/Php/ArrayColumnHelper.php | 27 +++- .../Analyser/nsrt/array-column-php82.php | 147 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/array-column.php | 147 ++++++++++++++++++ 3 files changed, 319 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/ArrayColumnHelper.php b/src/Type/Php/ArrayColumnHelper.php index 179d85bc04..a51480a4fe 100644 --- a/src/Type/Php/ArrayColumnHelper.php +++ b/src/Type/Php/ArrayColumnHelper.php @@ -53,6 +53,9 @@ public function getReturnIndexType(Type $arrayType, Type $indexType, Scope $scop $iterableValueType = $arrayType->getIterableValueType(); [$type, $certainty] = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope); + if ($type instanceof NeverType) { + return new IntegerType(); + } if ($certainty->yes()) { return $type; } @@ -98,7 +101,9 @@ public function handleConstantArray(ConstantArrayType $arrayType, Type $columnTy if (!$indexType->isNull()->yes()) { [$type, $certainty] = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope); - if ($certainty->yes()) { + if ($type instanceof NeverType) { + $keyType = null; + } elseif ($certainty->yes()) { $keyType = $type; } else { $keyType = TypeCombinator::union($type, new IntegerType()); @@ -147,7 +152,25 @@ private function getOffsetOrProperty(Type $type, Type $offsetOrProperty, Scope $ continue; } - $returnTypes[] = $type->getInstanceProperty($propertyName, $scope)->getReadableType(); + $property = $type->getInstanceProperty($propertyName, $scope); + if (!$scope->canReadProperty($property)) { + foreach ($type->getObjectClassReflections() as $classReflection) { + if ($classReflection->hasMethod('__isset') && $classReflection->hasMethod('__get')) { + return [new MixedType(), TrinaryLogic::createMaybe()]; + } + + if (!$classReflection->isFinal()) { + if ($property->isPrivate()) { + return [new MixedType(), TrinaryLogic::createMaybe()]; + } + + return [$property->getReadableType(), TrinaryLogic::createMaybe()]; + } + } + continue; + } + + $returnTypes[] = $property->getReadableType(); } } diff --git a/tests/PHPStan/Analyser/nsrt/array-column-php82.php b/tests/PHPStan/Analyser/nsrt/array-column-php82.php index e55e7a38ba..8e56e86395 100644 --- a/tests/PHPStan/Analyser/nsrt/array-column-php82.php +++ b/tests/PHPStan/Analyser/nsrt/array-column-php82.php @@ -237,3 +237,150 @@ public function doFoo(array $a): void } } + +class NonFinalObjectWithVisibility +{ + public int $pub = 1; + protected int $prot = 2; + private int $priv = 3; +} + +final class FinalObjectWithVisibility +{ + public int $pub = 1; + protected int $prot = 2; + private int $priv = 3; +} + +class ArrayColumnVisibilityNonFinalTest +{ + + /** @param array $objects */ + public function testNonFinal(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + + /** @param array{NonFinalObjectWithVisibility} $objects */ + public function testNonFinalConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + +} + +class ArrayColumnVisibilityFinalTest +{ + + /** @param array $objects */ + public function testFinal(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('array{}', array_column($objects, 'prot')); + assertType('array{}', array_column($objects, 'priv')); + } + + /** @param array{FinalObjectWithVisibility} $objects */ + public function testFinalConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('array{}', array_column($objects, 'prot')); + assertType('array{}', array_column($objects, 'priv')); + } + + /** @param array $objects */ + public function testNonPublicAsIndex(array $objects): void + { + assertType('array', array_column($objects, 'pub', 'pub')); + assertType('array', array_column($objects, 'pub', 'priv')); + } + +} + +class ArrayColumnVisibilityFromInsideTest +{ + + public int $pub = 1; + private int $priv = 2; + + /** @param list $objects */ + public function testFromInside(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + +} + +class ArrayColumnVisibilityFromChildTest extends NonFinalObjectWithVisibility +{ + + /** @param list $objects */ + public function testFromChild(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + +} + +final class ObjectWithIssetOnly +{ + private int $priv = 2; + + public function __isset(string $name): bool + { + return true; + } +} + +class ArrayColumnVisibilityWithIssetOnlyTest +{ + + /** @param array $objects */ + public function testWithIssetOnly(array $objects): void + { + assertType('array{}', array_column($objects, 'priv')); + } + +} + +class ObjectWithIsset +{ + public int $pub = 1; + private int $priv = 2; + + public function __isset(string $name): bool + { + return true; + } + + public function __get(string $name): mixed + { + return $this->$name; + } +} + +class ArrayColumnVisibilityWithIssetTest +{ + + /** @param array $objects */ + public function testWithIsset(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + + /** @param array{ObjectWithIsset} $objects */ + public function testWithIssetConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-column.php b/tests/PHPStan/Analyser/nsrt/array-column.php index 7049a5130b..ca2a819d77 100644 --- a/tests/PHPStan/Analyser/nsrt/array-column.php +++ b/tests/PHPStan/Analyser/nsrt/array-column.php @@ -252,3 +252,150 @@ public function doFoo(array $a): void } } + +class NonFinalObjectWithVisibility +{ + public int $pub = 1; + protected int $prot = 2; + private int $priv = 3; +} + +final class FinalObjectWithVisibility +{ + public int $pub = 1; + protected int $prot = 2; + private int $priv = 3; +} + +class ArrayColumnVisibilityNonFinalTest +{ + + /** @param array $objects */ + public function testNonFinal(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + + /** @param array{NonFinalObjectWithVisibility} $objects */ + public function testNonFinalConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + +} + +class ArrayColumnVisibilityFinalTest +{ + + /** @param array $objects */ + public function testFinal(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('array{}', array_column($objects, 'prot')); + assertType('array{}', array_column($objects, 'priv')); + } + + /** @param array{FinalObjectWithVisibility} $objects */ + public function testFinalConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('array{}', array_column($objects, 'prot')); + assertType('array{}', array_column($objects, 'priv')); + } + + /** @param array $objects */ + public function testNonPublicAsIndex(array $objects): void + { + assertType('array', array_column($objects, 'pub', 'pub')); + assertType('array', array_column($objects, 'pub', 'priv')); + } + +} + +class ArrayColumnVisibilityFromInsideTest +{ + + public int $pub = 1; + private int $priv = 2; + + /** @param list $objects */ + public function testFromInside(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + +} + +class ArrayColumnVisibilityFromChildTest extends NonFinalObjectWithVisibility +{ + + /** @param list $objects */ + public function testFromChild(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'prot')); + assertType('list', array_column($objects, 'priv')); + } + +} + +final class ObjectWithIssetOnly +{ + private int $priv = 2; + + public function __isset(string $name): bool + { + return true; + } +} + +class ArrayColumnVisibilityWithIssetOnlyTest +{ + + /** @param array $objects */ + public function testWithIssetOnly(array $objects): void + { + assertType('array{}', array_column($objects, 'priv')); + } + +} + +class ObjectWithIsset +{ + public int $pub = 1; + private int $priv = 2; + + public function __isset(string $name): bool + { + return true; + } + + public function __get(string $name): mixed + { + return $this->$name; + } +} + +class ArrayColumnVisibilityWithIssetTest +{ + + /** @param array $objects */ + public function testWithIsset(array $objects): void + { + assertType('list', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + + /** @param array{ObjectWithIsset} $objects */ + public function testWithIssetConstant(array $objects): void + { + assertType('array{int}', array_column($objects, 'pub')); + assertType('list', array_column($objects, 'priv')); + } + +} From 40cce7a1d9e33e0ddce1ee5d7556b1c2e6800461 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 16 Mar 2026 16:39:32 +0100 Subject: [PATCH 2/3] Account for non-final classes: child can override property visibility For non-final classes, a child class can widen visibility: - protected: type is preserved (invariant in PHP), treat as maybe - private: child can redeclare with any type, treat as mixed For final classes, non-accessible properties return empty array. Add implicit final test for new expression (asFinal via NewHandler). --- tests/PHPStan/Analyser/nsrt/array-column-php82.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/array-column-php82.php b/tests/PHPStan/Analyser/nsrt/array-column-php82.php index 8e56e86395..cc8a6cc3f4 100644 --- a/tests/PHPStan/Analyser/nsrt/array-column-php82.php +++ b/tests/PHPStan/Analyser/nsrt/array-column-php82.php @@ -301,6 +301,19 @@ public function testNonPublicAsIndex(array $objects): void } +class ArrayColumnVisibilityImplicitFinalTest +{ + + public function testNewExpression(): void + { + $objects = [new NonFinalObjectWithVisibility()]; + assertType('array{int}', array_column($objects, 'pub')); + assertType('array{}', array_column($objects, 'prot')); + assertType('array{}', array_column($objects, 'priv')); + } + +} + class ArrayColumnVisibilityFromInsideTest { From 4538d2f237c98c503e3fc8624138ea0c47d1f81b Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 16 Mar 2026 16:47:39 +0100 Subject: [PATCH 3/3] Use isFinalByKeyword instead of isFinal @final is just a PHPDoc convention with no runtime enforcement, so a child class can still extend and override property visibility. --- src/Type/Php/ArrayColumnHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayColumnHelper.php b/src/Type/Php/ArrayColumnHelper.php index a51480a4fe..824530751c 100644 --- a/src/Type/Php/ArrayColumnHelper.php +++ b/src/Type/Php/ArrayColumnHelper.php @@ -159,7 +159,7 @@ private function getOffsetOrProperty(Type $type, Type $offsetOrProperty, Scope $ return [new MixedType(), TrinaryLogic::createMaybe()]; } - if (!$classReflection->isFinal()) { + if (!$classReflection->isFinalByKeyword()) { if ($property->isPrivate()) { return [new MixedType(), TrinaryLogic::createMaybe()]; }