is_callable() must refine an array arm of array|callable to something that can actually be invokedarray<array-key, mixed> after the guard.
Three ways of consuming the narrowed value then disagreed, three lines apart:
a direct call, a first-class callable, and Closure::fromCallable(). The
unguarded call is the control. PHPStan rejects it; Psalm and mago stay
silent on invoking array|callable even without the guard, so it is
optional.
Source: carthage-software/mago#2385 (fixed in 1.50.0).
References:
- PHP is_callable()
- mago#2385| Analyzer | Version | Result | Diagnostics |
|---|---|---|---|
| phan | 6.0.10Phan 6.0.10 php-ast version 1.1.3 PHP version used to run Phan: 8.5.11 | Pass | No diagnostics reported. |
| phpstan | 2.3.1PHPStan - PHP Static Analysis Tool 2.3.1 | Pass reported Lv.7+ | regressions_is_callable_array_narrowing.php:47: Trying to invoke array<mixed>|(callable(): mixed) but it might not be a callable. [reported-from-level=7] With strict-rulesregressions_is_callable_array_narrowing.php:47: Trying to invoke array<mixed>|(callable(): mixed) but it might not be a callable. |
| psalm | 6.20.0Psalm 6.20.0@804f03c0c183d149fcd4b8c7c0c9b3fbf836d34c next: 7.0.0-rc2 |
Pass | No diagnostics reported. |
| mago | 1.57.0mago 1.57.0 | Pass | No diagnostics reported. |
| mir | 0.87.0mir 0.87.0 | Pass | No diagnostics reported. |
| phpantom | 0.11.1phpantom_lsp 0.11.1 | Pass | No diagnostics reported. |
| intelephense | 1.18.5intelephense 1.18.5 | Pass | No diagnostics reported. |
| phpactor | 2026.10.07.0Phpactor 2026.10.07.0 | Pass | No diagnostics reported. |
| phpy | 1.0.19409phpy 1.0.19409 | Pass | No diagnostics reported. |
| qodana | 262.10968.76PhpStorm 262.10968.76 | Pass | No diagnostics reported. |
| noverify | 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 | Pass | regressions_is_callable_array_narrowing.php:37: not null safety call in function Conformance\Tests\RegressionsIsCallableArrayNarrowing\takesClosure signature of param callback when calling function [notNullSafetyFunctionArgumentFunctionCall] regressions_is_callable_array_narrowing.php:38: not null safety call in function fromCallable signature of param callback [notNullSafetyFunctionArgumentVariable] |
| steins | 0.1.8steins 0.1.8 (2026-09-21 revision 76306a9) | Pass | No diagnostics reported. |
| phpstan-strict | 2.3.1PHPStan - PHP Static Analysis Tool 2.3.1 | Pass | regressions_is_callable_array_narrowing.php:47: Trying to invoke array<mixed>|(callable(): mixed) but it might not be a callable. |
| psalm-next | 7.0.0-rc2Psalm 7.0.0-rc2@20339e3fe5b8d03bcd8061030eb2a83ad50e5783 | Pass | No diagnostics reported. |
<?php
declare(strict_types=1);
namespace Conformance\Tests\RegressionsIsCallableArrayNarrowing;
/**
* `is_callable()` must refine an `array` arm of `array|callable` to something
* that can actually be invoked.
*
* Mago 1.49 left the array arm as `array<array-key, mixed>` after the guard.
* Three ways of consuming the narrowed value then disagreed, three lines apart:
* a direct call, a first-class callable, and `Closure::fromCallable()`. The
* unguarded call is the control. PHPStan rejects it; Psalm and mago stay
* silent on invoking `array|callable` even without the guard, so it is
* optional.
*
* Source: carthage-software/mago#2385 (fixed in 1.50.0).
*
* References:
* - PHP `is_callable()`
* - https://github.com/carthage-software/mago/issues/2385
*/
function takesClosure(\Closure $callback): void
{
$callback();
}
/**
* @param array<mixed>|callable $value
*/
function afterGuard(array|callable $value): void
{
if (\is_callable($value)) {
$value(); // Q: the array arm is callable after the guard
takesClosure($value(...)); // Q?: first-class callable of the narrowed value
takesClosure(\Closure::fromCallable($value)); // Q?: fromCallable of the narrowed value
}
}
/**
* @param array<mixed>|callable $value
*/
function beforeGuard(array|callable $value): void
{
$value(); // E?: the array arm is not callable without the guard — Psalm and mago stay silent here
}