PHPStan 2.2:未封闭数组形状与更安全的数组键
PHPStan 2.2 于 2026-05-28 发布,距离上一个 minor 版本(2.0)大约一年半,2.1.x 期间发了 55+ 个 release。这个版本主要改了两件与数组有关的事:数组形状(array shape)终于可以表达「已知键 + 额外键」,以及 array 这类标注里一直存在的键类型隐患有了显式检查和配置开关。
相关链接:
- 官方博客:https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys
- 配套文章:https://phpstan.org/blog/why-array-string-keys-are-not-type-safe
- Release:https://github.com/phpstan/phpstan/releases/tag/2.2.0
- 未封闭数组形状 PR:https://github.com/phpstan/phpstan-src/pull/5501
- PHPDoc 类型文档:https://phpstan.org/writing-php-code/phpdoc-types
- 仓库:https://github.com/phpstan/phpstan
同批变更还包括:新类型 decimal-int-string 与 non-decimal-int-string(#5279);新配置 reportUnsafeArrayStringKeyCasting;对参数里常量的检查(#5256、#12850),新增 error identifier argument.exclusiveConstants、argument.invalidConstant、argument.bitmaskNotAllowed;检测命名参数在子类型中被改名(#7434、#5279),新增 argument.parameterRenamedInSubtype。未封闭数组形状由 #5501、#13565、#8438、#11494、#12110、#14032 完成。
问题复现
数组键的隐式转换
PHP 里字符串键 '8' 会被存成 int 8;'08' 保持字符串,因为它不是合法十进制整数。float 键截断为 int(8.7 → 8,PHP 8.1 起 deprecated);bool 转 int;null 变空字符串 ''(PHP 8.5 起 deprecated)。
所以标注 array 时,只要键来自外部输入或变量拼接,运行时可能出现 int 键,foreach 拿到的 $key 不一定是 string。判定一个字符串是否会保持字符串键:
if ((string) (int) $x === $x) {
// $x is decimal-int-string
} else {
// $x is non-decimal-int-string
}
array shape 一直不密封
/**
* @param array{a: int} $a
*/
function doFoo(array $a): void { ... }
PHPStan 不检查是否有多余键,传 array{a: int, b: string} 也接受;甚至传普通 array,只要 isset($b['a']) 检查过就放行:
/**
* @param array $b
*/
function doBar(array $b): void {
if (isset($b['a'])) {
doFoo($b); // No errors!
}
}
后果是类型推断会给出错误结论:
function doFoo(array $a): void {
\PHPStan\dumpType(count($a)); // reports 1, but that's a lie
echo $a['b'] ?? 'foo'; // PHPStan reports the offset doesn't exist - also a lie, b may well exist at runtime
foreach ($a as $k => $v) {
\PHPStan\dumpType($k); // Reports 'a', but user might pass more keys
\PHPStan\dumpType($v); // Reports int, but should be mixed
}
}
新类型:decimal-int-string 与 non-decimal-int-string
decimal-int-string 指 PHP 视为合法十进制整数、用作数组键会被转 int 的字符串,例如 '0'、'123'、'-1'。
non-decimal-int-string 是其余字符串,例如 'hello'、'08'、'+1'、'1.5'、'00'、'18E+3'、'1,3'。这类字符串用作键永远不会被转换。
/** @param array $data */
function processData(array $data): void
{
foreach ($data as $key => $value) {
processKey($key); // 一定是 string
}
}
function doFoo(string $s)
{
$a = [];
$a[$s] = 1; // might produce an int key
// 报错原文:
// Parameter #1 $data of function processData
// expects array, non-empty-array given.
processData($a);
}
配置 reportUnsafeArrayStringKeyCasting 三档
null(默认):与今天一致,不做额外检查。
'detect':类型标注的 array 仍接受其他 array,但取键时(foreach、array_keys)键类型变为 int|non-decimal-int-string,从而暴露问题而不必改所有标注。
'prevent':array 实际按 array 处理,只接受安全键数组;任何 string 用作数组键都会被收窄为 int|non-decimal-int-string。
parameters:
reportUnsafeArrayStringKeyCasting: detect # or: prevent
官方建议先在 detect 下看影响面,再切到 prevent。两种严格模式在真实项目里都会多出几百个错误。
未封闭数组形状的语法与交叉结果
array{foo: int, ...}:约束额外键的键/值类型array{foo: int, ...}:<>内单个类型是值类型,键隐含为array-key(int|string的 benevolent union)list{X, Y, ...}:list 在列出的键之后只允许单一值类型,键始终 intarray{foo: int, ...}:裸...等价...
交叉运算结果:
array{a: int, ...}&array{b: string, ...}→array{a: int, b: string, ...}array{a: int, b: string, ...}&array{a: int, ...}→array{a: int, b: string, ...}array{a: int, ...}&array{a: string, ...}→never(同一已知键值类型冲突)
交叉只在至少一侧允许额外键时才有意义;array{a: int}&array{b: string} 依旧无意义、不支持。密封的数组形状被拒绝时,table formatter 会带 💡 前缀给出提示:
- Sealed array shape can only accept a constant array. Extra keys are not allowed.
- Sealed array shape does not accept array with extra key 'year'.
- Sealed array shape does not accept unsealed array shape.
未封闭数组形状的工作由 Sovereign Tech Fund 资助。
bleeding edge:密封形状才真正密封
在 bleeding edge 下,sealed array shape 真正密封:array{a: int, b: string} 不接受额外键,也不接受普通数组。启用方式:
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
升级步骤与取舍
用 composer 把 phpstan/* 一起升到 ^2.2(phpstan-doctrine、phpstan-phpunit、phpstan-strict-rules、larastan 等扩展同步),命令:
composer update 'phpstan/*' -W
2.2 的默认行为基本兼容:未封闭形状可用、密封形状维持旧的不一致行为、键转换检查默认关闭。要提前使用严格行为才 include bleedingEdge.neon,它没有兼容承诺,minor 版本内也会变。
新增 identifier 可以按需用 ignoreErrors 的 identifier 字段精确抑制,例如 argument.bitmaskNotAllowed。