Bug report
isset() on a nested array offset makes PHPStan treat the intermediate offset as definitely-existing for the rest of the enclosing scope, instead of only inside the check's true branch.
After isset($r['K']['Port']), the type of $r['K'] loses its "may not exist" — even though the statement tells us nothing about $r['K'] once the check is over (if $r['K'] does not exist, isset() is simply false and the offset still does not exist).
This is directly observable with dumpType, and it already produces a false positive on a plain level: 10 run.
Code snippet that reproduces the problem
https://phpstan.org/r/9648cca2-79d8-418e-8fd7-106da24a9ade
<?php
declare(strict_types=1);
/** @param array<string, array{Port: int, Secure: string|null}> $r */
function nestedIssetLeak(array $r): void
{
\PHPStan\dumpType($r['K'] ?? null); // array{Port: int, Secure: string|null}|null <- correct
$port = isset($r['K']['Port']) ? $r['K']['Port'] : null;
\PHPStan\dumpType($r['K'] ?? null); // array{Port: int, Secure: string|null} <- leaked, |null dropped
$secure = $r['K']['Secure'] ?? null;
echo $port, $secure;
}
/** @param array<string, array{Port: int, Secure: string|null}> $r */
function alsoAfterPlainIf(array $r): void
{
if (isset($r['K']['Port'])) {
echo $r['K']['Port'];
}
$secure = $r['K']['Secure'] ?? null;
echo $secure;
}
/** @param array<string, array{Port: int, Secure: string|null}> $r */
function notWithCoalesce(array $r): void
{
$port = $r['K']['Port'] ?? null; // `??` instead of isset(): no leak
$secure = $r['K']['Secure'] ?? null; // not reported
echo $port, $secure;
}
/** @param array<string, string|null> $r */
function notWithSingleLevel(array $r): void
{
$port = isset($r['K']) ? $r['K'] : null; // single-level offset: no leak
$secure = $r['K'] ?? null; // not reported
echo $port, $secure;
}
Expected output
Line 12 should dump array{Port: int, Secure: string|null}|null, same as line 8, and no error should be reported.
Actual output
level: 10, no bleeding edge, PHPStan 2.2.6, PHP 8.5.5:
final.php:8:Dumped type: array{Port: int, Secure: string|null}|null [identifier=phpstan.dumpType]
final.php:12:Dumped type: array{Port: int, Secure: string|null} [identifier=phpstan.dumpType]
final.php:12:Offset 'K' on non-empty-array<string, array{Port: int, Secure: string|null}> on left side of ?? always exists and is not nullable. [identifier=nullCoalesce.offset]
With bleedingEdge.neon the same leak additionally trips the newer nullCoalesce.unnecessary rule twice:
final.php:14:Coalesce operator ?? is unnecessary because the left side is always set and the right side is null. [identifier=nullCoalesce.unnecessary]
final.php:26:Coalesce operator ?? is unnecessary because the left side is always set and the right side is null. [identifier=nullCoalesce.unnecessary]
Regression: introduced in 2.1.40
Bisected with the same snippet (PHP 8.5.5, level: 10, no bleeding edge), looking at the line 12 dumpType:
| Version |
dumpType($r['K'] ?? null) after the isset |
| 1.6.0 – 1.12.0 (level 9) |
array{...}|null ✅ |
| 2.0.0, 2.1.0 … 2.1.39 |
array{...}|null ✅ |
| 2.1.40 |
array{...} ❌ |
| 2.1.41 … 2.2.6 |
array{...} ❌ |
Root cause: the falsey branch is narrowed to never
/** @param array<string, array{Port: int, Secure: string|null}> $r */
function branches(array $r): void
{
if (isset($r['K']['Port'])) {
\PHPStan\dumpType($r['K'] ?? null); // expected: array{...}
} else {
\PHPStan\dumpType($r['K'] ?? null); // expected: array{...}|null
}
\PHPStan\dumpType($r['K'] ?? null); // expected: array{...}|null
}
2.1.39 L9: array{Port: int, Secure: string|null}
L11: array{Port: int, Secure: string|null}|null
L13: array{Port: int, Secure: string|null}|null
2.1.40 L9: array{Port: int, Secure: string|null}
L11: *NEVER*
L13: array{Port: int, Secure: string|null}
In the else branch, $r['K'] is narrowed to *NEVER* — PHPStan believes it is impossible for isset($r['K']['Port']) to be false. Merging the true branch (array{...}) with a never falsey branch then yields array{...}, which is how the |null disappears after the statement.
Reachability is not affected — an else/early-return after such an isset() is not reported as dead code — so the symptom stays confined to types.
The likely culprit in 2.1.40 is "Fix isset() falsey branch not narrowing array type for dim fetches" (phpstan-src#4983, referencing #9908, #10544, #8724, #5128, #12401): it seems to over-narrow the falsey branch to never when the intermediate offset may itself be missing.
Practical impact: the nullCoalesce.unnecessary rule added since 2.2.5 turned this latent wrong type into ~120 false positives on a previously green codebase after a patch upgrade — every $a['x']['y'] ?? null that happens to sit after an isset($a['x'][...]) earlier in the same argument list.
Conditions
| Variant |
Leaks |
isset($r['K']['Port']) in a ternary |
yes |
isset($r['K']['Port']) in a plain if |
yes |
$r['K']['Port'] ?? null instead of isset() |
no |
isset($r['K']) — single level |
no |
The nested offset is required; the leak concerns the intermediate dimension only.
Possibly related
Did PHPStan help you today? Did it make you happy in any way?
Yes — the very run that surfaced this also caught a genuine bug, where a @param array{...} shape declared a key as required while one caller built its SELECT without that column.
Bug report
isset()on a nested array offset makes PHPStan treat the intermediate offset as definitely-existing for the rest of the enclosing scope, instead of only inside the check's true branch.After
isset($r['K']['Port']), the type of$r['K']loses its "may not exist" — even though the statement tells us nothing about$r['K']once the check is over (if$r['K']does not exist,isset()is simplyfalseand the offset still does not exist).This is directly observable with
dumpType, and it already produces a false positive on a plainlevel: 10run.Code snippet that reproduces the problem
https://phpstan.org/r/9648cca2-79d8-418e-8fd7-106da24a9ade
Expected output
Line 12 should dump
array{Port: int, Secure: string|null}|null, same as line 8, and no error should be reported.Actual output
level: 10, no bleeding edge, PHPStan 2.2.6, PHP 8.5.5:With
bleedingEdge.neonthe same leak additionally trips the newernullCoalesce.unnecessaryrule twice:Regression: introduced in 2.1.40
Bisected with the same snippet (PHP 8.5.5,
level: 10, no bleeding edge), looking at the line 12dumpType:dumpType($r['K'] ?? null)after the issetarray{...}|null✅array{...}|null✅array{...}❌array{...}❌Root cause: the falsey branch is narrowed to
neverIn the
elsebranch,$r['K']is narrowed to*NEVER*— PHPStan believes it is impossible forisset($r['K']['Port'])to be false. Merging the true branch (array{...}) with aneverfalsey branch then yieldsarray{...}, which is how the|nulldisappears after the statement.Reachability is not affected — an
else/early-return after such anisset()is not reported as dead code — so the symptom stays confined to types.The likely culprit in 2.1.40 is "Fix isset() falsey branch not narrowing array type for dim fetches" (phpstan-src#4983, referencing #9908, #10544, #8724, #5128, #12401): it seems to over-narrow the falsey branch to
neverwhen the intermediate offset may itself be missing.Practical impact: the
nullCoalesce.unnecessaryrule added since 2.2.5 turned this latent wrong type into ~120 false positives on a previously green codebase after a patch upgrade — every$a['x']['y'] ?? nullthat happens to sit after anisset($a['x'][...])earlier in the same argument list.Conditions
isset($r['K']['Port'])in a ternaryisset($r['K']['Port'])in a plainif$r['K']['Port'] ?? nullinstead ofisset()isset($r['K'])— single levelThe nested offset is required; the leak concerns the intermediate dimension only.
Possibly related
unset($arr['foo']['bar']). Theisset()path seems to have kept the behaviour.isset()on a\stdClassproperty changing subsequent behaviour.ifnarrows another value, and 2.2.3 added a newin_array/function.alreadyNarrowedTypesymptom #14908 / Object property narrowing leaks out of a previous if when a 2-case enum is narrowed with !== #14807 — other cases of narrowing leaking out of a condition.Did PHPStan help you today? Did it make you happy in any way?
Yes — the very run that surfaced this also caught a genuine bug, where a
@param array{...}shape declared a key as required while one caller built its SELECT without that column.