Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ Many built-in PHP functions need `DynamicFunctionReturnTypeExtension` implementa
- Extensions live in `src/Type/Php/` and are registered in `conf/services.neon`
- Each reads the argument types from `Scope::getType()` and returns a more precise `Type`

### toArrayKey() and numeric string coercion

`Type::toArrayKey()` converts a value type to its array key representation. `StringType::toArrayKey()` returns `string`, but PHP casts numeric strings (like `'123'`) to integer keys. `ConstantStringType::toArrayKey()` correctly handles this (e.g. `'1'` → `int(1)`), and `AccessoryNumericStringType::toArrayKey()` returns `int|numeric-string`. However, a general `string` where `isNumericString()` returns `maybe` needs special handling in extensions that create arrays from values — the key type should be `int|string` (array-key), not just `string`. This applies to `array_count_values`, `array_flip`, and similar functions where input values become output keys.

### Function signature corrections (`src/Reflection/SignatureMap/`)

PHPStan maintains its own signature map for built-in PHP functions in `functionMap.php` and delta files. Fixes involve:
Expand Down
10 changes: 9 additions & 1 deletion src/Type/Php/ArrayCountValuesDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,16 @@ public function getTypeFromFunctionCall(
continue;
}

$keyType = $itemType->toArrayKey();

// PHP casts numeric strings to integer keys, so a general string
// that might be numeric should produce int|string keys
if ($itemType->isString()->yes() && $itemType->isNumericString()->maybe()) {
$keyType = TypeCombinator::union($keyType, new IntegerType());
}
Comment on lines +57 to +63
Copy link
Contributor

@VincentLanglet VincentLanglet Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Markus did a similar fix with #4798 (comment) and I commented on it, I dunno if you saw it @ondrejmirtes.

This will be globally solved with the issue phpstan/phpstan#6847 and cannot/shouldn't be handled locally.

I feel like this fix is not really the good one since now we're having a different behavior will all the other array<string> occurences, see https://phpstan.org/r/65e09a6e-3613-4f68-9b68-5bb5fdfd6b7a

And especially the fact that now

foreach (array_count_values($arr) as $key => $count) {
     $value = $arr[$key];
}

will report an issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking it should be a benevolent union.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also found another bug with this change
https://phpstan.org/r/f929c97d-ddc5-4318-9cf8-0700c0ab0a43


$outputTypes[] = new IntersectionType([
new ArrayType($itemType->toArrayKey(), IntegerRangeType::fromInterval(1, null)),
new ArrayType($keyType, IntegerRangeType::fromInterval(1, null)),
new NonEmptyArrayType(),
]);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array-count-values.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function returnsStringOrObjectArray(): array
}

// Objects are ignored by array_count_values, with a warning emitted.
assertType('non-empty-array<string, int<1, max>>', array_count_values(returnsStringOrObjectArray()));
assertType('non-empty-array<int|string, int<1, max>>', array_count_values(returnsStringOrObjectArray()));

class StringableObject
{
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/bug-12222.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php // lint >= 8.1

namespace Bug12222;
namespace Bug12222Nsrt;

use function PHPStan\Testing\assertType;

Expand Down Expand Up @@ -51,11 +51,11 @@ public function process(array $data): array
'startDate' => $data['startDate'],
];

assertType('Bug12222\ContractStatus|null', $contract['status']);
assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']);
$contract['isActive'] = $contract['status']?->isActive();
assertType('Bug12222\ContractStatus|null', $contract['status']);
assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']);
$contract['isBeingTerminated'] = $contract['status']?->isBeingTerminated();
assertType('Bug12222\ContractStatus|null', $contract['status']);
assertType('Bug12222Nsrt\ContractStatus|null', $contract['status']);
$contract['isTerminated'] = $contract['status']?->isTerminated();

return $contract;
Expand Down
29 changes: 29 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13996.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);

namespace Bug13996;

use function PHPStan\Testing\assertType;

/**
* @param array<string> $strings
*/
function strings(array $strings): void
{
assertType('non-empty-array<int|string, int<1, max>>', array_count_values($strings));
}

/**
* @param array<int> $ints
*/
function ints(array $ints): void
{
assertType('non-empty-array<int, int<1, max>>', array_count_values($ints));
}

/**
* @param array<int|string> $mixed
*/
function intOrString(array $mixed): void
{
assertType('non-empty-array<int|string, int<1, max>>', array_count_values($mixed));
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Rules/Classes/data/bug-14097.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php
<?php // lint >= 8.0

namespace Bug14097;

Expand Down
Loading