From 82272998da4cabe8c537107bedf97c96ebb9f470 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:12:42 +0000 Subject: [PATCH 1/4] Mark `array_filter()`, `array_map()`, `array_reduce()` and sibling non-mutating callback functions as having no side effects - Add `hasSideEffects => false` entries for `array_filter`, `array_map` and `array_reduce` to `bin/functionMetadata_original.php` and the generated `resources/functionMetadata.php`. These functions do not mutate their arguments and have no side effects of their own; any side effects come from the supplied callback, whose impure points already propagate to the caller because callable parameters of functions are treated as immediately invoked. - Sweep the rest of the non-mutating array callback family that had the same problem: `array_find`, `array_find_key`, `array_any`, `array_all`. - Also mark the analogous `preg_replace_callback` and `preg_replace_callback_array` as side-effect-free. - An impure callback (e.g. one that echoes) still suppresses the "no effect" report because its impure points are merged into the statement; only genuinely pure callbacks make the whole call a no-op. - Update `tests/.../data/file-without-errors.php` and `tests/.../data/discussion-7124.php` so they use the result of the now-pure calls instead of relying on the previous (buggy) behavior where these calls were never reported. - Considered but deliberately excluded `call_user_func`/`call_user_func_array`: they are the general-purpose invocation primitive rather than part of the array-callback family; the propagation machinery would make them safe, but the behavior change is broader than this issue. --- bin/functionMetadata_original.php | 9 ++++ resources/functionMetadata.php | 9 ++++ .../PHPStan/Analyser/data/discussion-7124.php | 16 +++---- .../Command/data/file-without-errors.php | 2 +- ...ionStatementWithoutSideEffectsRuleTest.php | 45 +++++++++++++++++++ .../Rules/Functions/data/bug-11101-php84.php | 23 ++++++++++ .../Rules/Functions/data/bug-11101.php | 38 ++++++++++++++++ 7 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101-php84.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101.php diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 78ac65db296..630783cca68 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -20,6 +20,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -32,6 +34,9 @@ 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], @@ -42,6 +47,7 @@ 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -49,6 +55,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -237,6 +244,8 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], + 'preg_replace_callback' => ['hasSideEffects' => false], + 'preg_replace_callback_array' => ['hasSideEffects' => false], 'readfile' => ['hasSideEffects' => true], 'rename' => ['hasSideEffects' => true], 'rewind' => ['hasSideEffects' => true], diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 7a5515b967d..ced46d1808b 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -725,6 +725,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -737,6 +739,9 @@ 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['hasSideEffects' => false], 'array_first' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], @@ -750,6 +755,7 @@ 'array_key_last' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], 'array_last' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -757,6 +763,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -1616,6 +1623,8 @@ 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], 'preg_quote' => ['hasSideEffects' => false], + 'preg_replace_callback' => ['hasSideEffects' => false], + 'preg_replace_callback_array' => ['hasSideEffects' => false], 'preg_split' => ['hasSideEffects' => false], 'property_exists' => ['hasSideEffects' => false], 'quoted_printable_decode' => ['hasSideEffects' => false], diff --git a/tests/PHPStan/Analyser/data/discussion-7124.php b/tests/PHPStan/Analyser/data/discussion-7124.php index 29a8d1f5f50..24fe1f28eb6 100644 --- a/tests/PHPStan/Analyser/data/discussion-7124.php +++ b/tests/PHPStan/Analyser/data/discussion-7124.php @@ -33,30 +33,30 @@ function filter(array $array, ?callable $callback = null, int $mode = ARRAY_FILT function () { // This one does fail, as both the value + key is asked and the key + value is used - filter( + echo count(filter( [false, true, false], static fn (int $key, bool $value): bool => 0 === $key % 2 && $value, mode: ARRAY_FILTER_USE_BOTH, - ); + )); // This one should fail, as both the value + key is asked but only the key is used - filter( + echo count(filter( [false, true, false], static fn (int $key): bool => 0 === $key % 2, mode: ARRAY_FILTER_USE_BOTH, - ); + )); // This one should fail, as only the key is asked but the value is used - filter( + echo count(filter( [false, true, false], static fn (bool $value): bool => $value, mode: ARRAY_FILTER_USE_KEY, - ); + )); // This one should fail, as only the value is asked but the key is used - filter( + echo count(filter( [false, true, false], static fn (int $key): bool => 0 === $key % 2, mode: 0, - ); + )); }; diff --git a/tests/PHPStan/Command/data/file-without-errors.php b/tests/PHPStan/Command/data/file-without-errors.php index 08929907d3c..48196273364 100644 --- a/tests/PHPStan/Command/data/file-without-errors.php +++ b/tests/PHPStan/Command/data/file-without-errors.php @@ -1,3 +1,3 @@ analyse([__DIR__ . '/data/bug-12224.php'], []); } + public function testBug11101(): void + { + $this->analyse([__DIR__ . '/data/bug-11101.php'], [ + [ + 'Call to function array_filter() on a separate line has no effect.', + 8, + ], + [ + 'Call to function array_map() on a separate line has no effect.', + 9, + ], + [ + 'Call to function array_reduce() on a separate line has no effect.', + 10, + ], + [ + 'Call to function array_filter() on a separate line has no effect.', + 13, + ], + [ + 'Call to function preg_replace_callback() on a separate line has no effect.', + 14, + ], + [ + 'Call to function preg_replace_callback_array() on a separate line has no effect.', + 15, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testBug11101Php84(): void + { + $this->analyse([__DIR__ . '/data/bug-11101-php84.php'], [ + [ + 'Call to function array_any() on a separate line has no effect.', + 8, + ], + [ + 'Call to function array_all() on a separate line has no effect.', + 9, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php new file mode 100644 index 00000000000..24c1a022443 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php @@ -0,0 +1,23 @@ + 'strtoupper'], 'abc'); +} + +function doBar(array $array, callable $cb): void +{ + // impure callbacks - NOT reported + array_filter($array, function ($v) { + echo $v; + return true; + }); + array_map(function ($v) { + echo $v; + return $v; + }, $array); + array_reduce($array, function ($carry, $item) { + echo $item; + return $carry; + }, 0); + array_map('printf', $array); + + // unknown callable purity - NOT reported + array_map($cb, $array); + array_filter($array, $cb); +} From 4cc8eee8ff8e3f170647d3d80ec878a04c5f612e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 21 Jun 2026 20:05:50 +0000 Subject: [PATCH 2/4] Add regression test for pure method calling array_map() with pure callback Closes https://github.com/phpstan/phpstan/issues/11100 Co-Authored-By: Claude Opus 4.8 --- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 6 ++++ tests/PHPStan/Rules/Pure/data/bug-11100.php | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/bug-11100.php diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 8287809407a..8cfc8f37341 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -276,6 +276,12 @@ public function testBug12048(): void $this->analyse([__DIR__ . '/data/bug-12048.php'], []); } + public function testBug11100(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11100.php'], []); + } + public function testBug12224(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Pure/data/bug-11100.php b/tests/PHPStan/Rules/Pure/data/bug-11100.php new file mode 100644 index 00000000000..840870c97e0 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-11100.php @@ -0,0 +1,30 @@ + $getParams + * + * @phpstan-pure + */ + public function build(array $getParams, string $trailingSlash, bool $useSlashSeparator): string + { + if ($getParams === []) { + return ''; + } + + if ($useSlashSeparator) { + return '/' . implode('/', array_map( + static function (string $key, string|int $value): string { + return rawurlencode($key) . '/' . rawurlencode((string) $value); + }, + array_keys($getParams), + $getParams + )) . $trailingSlash; + } + + return $trailingSlash . '?' . http_build_query($getParams); + } +} From cf7ee517901b6ecc7d6643867b1241d3f331c4f6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 21 Jun 2026 20:05:50 +0000 Subject: [PATCH 3/4] Add regression test for array_map() with a pure closure variable having no effect Co-Authored-By: Claude Opus 4.8 --- ...ionStatementWithoutSideEffectsRuleTest.php | 10 ++++++++++ .../Functions/data/bug-11101-pure-closure.php | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101-pure-closure.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index f7b4196412d..210be4b0b54 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -136,6 +136,16 @@ public function testBug11101Php84(): void ]); } + public function testBug11101PureClosure(): void + { + $this->analyse([__DIR__ . '/data/bug-11101-pure-closure.php'], [ + [ + 'Call to function array_map() on a separate line has no effect.', + 17, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101-pure-closure.php b/tests/PHPStan/Rules/Functions/data/bug-11101-pure-closure.php new file mode 100644 index 00000000000..73e313104fa --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101-pure-closure.php @@ -0,0 +1,20 @@ + $args */ + public function test(array $args): void + { + $pureFx = static function (string $v): void {}; + + assert(isset($args[0])); + + $pureFx($args[0]); + + array_map($pureFx, $args); + } + +} From 563d86d18764a0d792511b454855d1f0400b3739 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 21 Jun 2026 22:17:26 +0200 Subject: [PATCH 4/4] test maybe --- ...llToFunctionStatementWithoutSideEffectsRuleTest.php | 4 ++++ tests/PHPStan/Rules/Functions/data/bug-11101-php84.php | 10 ++++++++++ tests/PHPStan/Rules/Pure/data/bug-11100.php | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 210be4b0b54..fc19f617885 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -133,6 +133,10 @@ public function testBug11101Php84(): void 'Call to function array_all() on a separate line has no effect.', 9, ], + [ + 'Call to function array_any() on a separate line has no effect.', + 31, + ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php index 24c1a022443..9130eb9f666 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php @@ -21,3 +21,13 @@ function doBar(array $array): void return true; }); } + +function maybeImpure(array $array): void +{ + $cb = rand(0,1) ? 'is_string' : function ($v) { + echo $v; + return true; + }; + array_any($array, $cb); + +} diff --git a/tests/PHPStan/Rules/Pure/data/bug-11100.php b/tests/PHPStan/Rules/Pure/data/bug-11100.php index 840870c97e0..a0e3f5f3848 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-11100.php +++ b/tests/PHPStan/Rules/Pure/data/bug-11100.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug11100;