Psalm 的 ConstantDeclarationInTrait:为什么 PHP 8.2 之前 Trait 里不能声明常量
Psalm 的 ConstantDeclarationInTrait:为什么 PHP 8.2 之前 Trait 里不能声明常量
本文聚焦 Psalm 的问题类型 ConstantDeclarationInTrait:它何时被触发、底层由哪个扫描器基于哪个 PHP 版本判断做出这一判定,以及如何通过 psalm.xml 中的 phpVersion 配置控制它的出现。读完后你将理解 Psalm 在“解析/扫描阶段”而非“分析阶段”提前拦截 Trait 常量声明的原理,并掌握在不同目标 PHP 版本下正确配置静态分析的方法。
问题定义:Trait 中声明常量的致命错误
ConstantDeclarationInTrait 在 PHP < 8.2.0 下、一个 trait 声明了常量时被 Psalm 报告。原文档给出的最小复现代码是:
<?php
trait A {
const B = 0;
}
触发该问题的后果是抛出致命错误(fatal error)。PHP 语言规范直到 8.2 才允许在 trait 中声明类常量(见 RFC "Class constants of enums" 之前的配套变更中引入的 trait constants),因此在 8.2 之前的任何 PHP 运行时中,上述代码在编译期即失败,根本运行不到任何业务逻辑。
ConstantDeclarationInTrait 在 issues 总览 和 error_levels 文档 的列表中都列于最前,可见其属于“代码无法运行”级别的最高优先级问题。
实现机制:扫描器在 Trait 存储上提前拦截
该问题对应的 Issue 类是 src/Psalm/Issue/ConstantDeclarationInTrait.php,结构非常简洁:
final class ConstantDeclarationInTrait extends CodeIssue
{
public const ERROR_LEVEL = -1;
public const SHORTCODE = 315;
}
ERROR_LEVEL = -1:表示这是一个“致命”级别问题,不属于普通的错误/警告/信息分级体系(-1高于error级别),意味着它在默认配置下无法被降级忽略掉——因为代码本身就无法通过 PHP 编译;SHORTCODE = 315:是 Psalm 报告(尤其 JSON/SARIF 等机器可读输出)中该问题类型对应的数字编码。
判定逻辑位于类/接口/Trait 扫描阶段。从 ClassLikeNodeScanner::visitClassConstDeclaration() 的源码可以看到核心条件:
private function visitClassConstDeclaration(
PhpParser\Node\Stmt\ClassConst $stmt,
ClassLikeStorage $storage,
string $fq_classlike_name,
): void {
if ($storage->is_trait && $this->codebase->analysis_php_version_id < 8_02_00) {
IssueBuffer::maybeAdd(new ConstantDeclarationInTrait(
'Traits cannot declare constants until PHP 8.2.0',
new CodeLocation($this->file_scanner, $stmt),
));
return;
}
// ... 后续才是正常的常量类型解析、@var docblock 提取等逻辑
}
两个关键判据缺一不可:
$storage->is_trait——当前被扫描的类样体(classlike)必须是 trait;$this->codebase->analysis_php_version_id < 8_02_00——目标分析 PHP 版本(注意不是 Psalm 自身运行时的 PHP 版本)低于8.2.0,版本 ID 采用主版本 × 10000 + 次版本 × 100的编码方式,因此8_02_00即 8.2.0。
注意这里的细节:一旦命中条件,maybeAdd 之后立即 return,不再继续执行常量类型解析。这意味着在目标版本 < 8.2 时,该 trait 常量不会进入 Psalm 的类型模型——反正这段代码在运行时根本无法存活。
报告链路:Issue 如何到达最终输出
ConstantDeclarationInTrait 继承自 CodeIssue。CodeIssue::toIssueData() 会把 SHORTCODE(315)、ERROR_LEVEL(-1)、文件路径、行号、列号与选中文本(即 const B = 0; 这一行)打包进 IssueData,再经由 IssueBuffer::maybeAdd() 进入统一的问题缓冲区,最终随报告器(控制台、JSON、SARIF 等)输出。也就是说,你在报告中看到的问题名、短码、代码位置都来自这条链路,且位置精确定位到触发问题的 ClassConst 语句节点上。
analysis_php_version_id 从哪来:决定该问题是否出现的版本判定
源码里的判断依赖 $codebase->analysis_php_version_id,它在 Codebase.php 中初始化为当前运行时 PHP_VERSION_ID,之后可以被显式指定覆盖。在 ProjectAnalyzer.php 中可以看到解析流程:
[$php_major_version, $php_minor_version] = explode('.', $version);
$analysis_php_version_id = $php_major_version * 10_000 + $php_minor_version * 100;
if ($this->codebase->analysis_php_version_id !== $analysis_php_version_id) {
// reset parser when php version changes
StatementsProvider::clearParser();
}
$this->codebase->analysis_php_version_id = $analysis_php_version_id;
$this->codebase->php_version_source = $source;
可以推断版本来源(php_version_source)按优先级大致为:命令行 --php-version 参数 > 项目 composer.json 中的平台 php 约束 > psalm.xml 的 <phpVersion> 配置 > 运行时 PHP 版本。其中 psalm.xml 配置在 Config.php 中被读取:
if (isset($config_xml['phpVersion'])) {
$config->configured_php_version = (string) $config_xml['phpVersion'];
}
且 config.xsd 对版本号格式有约束(config.xsd 中的 phpVersion 元素)。
这一点对实操非常关键:即使你本机运行 PHP 8.3(trait 常量完全合法),只要项目声明的目标版本是 8.1,Psalm 仍会报告 ConstantDeclarationInTrait。反之,如果目标版本 ≥ 8.2,同一份代码就不会触发该问题。这也意味着该问题是一个“环境敏感型”问题:同一份代码在不同 phpVersion 配置下报告结果不同。
测试验证:PHP 8.1 下精确复现
tests/TraitTest.php 中包含了对应的回归测试用例:
'constant declaration in trait, php <8.2.0' => [
'code' => <<<'PHP'
<?php
trait A { const B = 0; }
PHP,
'error_message' => 'ConstantDeclarationInTrait',
'ignored_issues' => [],
'php_version' => '8.1',
],
测试显式将 php_version 设为 8.1,验证 trait A { const B = 0; } 精确触发 ConstantDeclarationInTrait。注意用例名特意写成 "php <8.2.0",与文档描述、源码条件一一对应;而 TraitTest 中其他用例(如 duplicateTraitProperty 触发 DuplicateProperty)不带版本限定,说明常量重复等冲突检测在 8.2+ 场景下照常工作,本问题只针对“trait + 旧版本”的组合。
版本相关的连锁影响:不只是少报一个问题
analysis_php_version_id 还驱动 Psalm 加载对应的 PHP 核心 stub。在 Config::visitPreloadedStubFiles() 中可以看到成对的版本判断:
if (PHP_VERSION_ID < 8_02_00 && $codebase->analysis_php_version_id >= 8_02_00) {
$stringable_path = dirname(__DIR__, 2) . '/stubs/Php82.phpstub';
// ...
$core_generic_files[] = $stringable_path;
}
含义是:当“目标版本 ≥ 8.2”但“运行 Psalm 的 PHP < 8.2”时,Psalm 会预加载 stubs/Php82.phpstub 等版本化 stub 文件,用静态声明补足本机反射得不到的新类与新特性。可以推断同一机制下,< 8.2 的目标版本则不会加载这些新特性声明,从而保持类型模型与目标运行时一致——trait 常量检查正是这套“按版本裁剪语言特性”策略中的一环。
如何处置该问题
由于这是编译期致命错误,处置方式只有两条路径:
- 修改代码(首选):将常量从 trait 中移出。常见做法是把常量声明到使用它的各个类中,或抽取到一个基类/接口常量容器(interface 常量在旧版本中可用)中,trait 内通过
use继承的常量来引用; - 确认目标版本(如属误报):如果项目确实运行在 PHP 8.2+ 且 trait 常量是有意为之,检查
psalm.xml的<phpVersion>与 composer.json 的php平台约束是否被正确声明为目标 8.2+,使analysis_php_version_id ≥ 8.2.0,该问题即不再报告。
在报告层面,ConstantDeclarationInTrait 已在 config.xsd 的 Issue 元素白名单中注册(<xs:element name="ConstantDeclarationInTrait" type="IssueHandlerType" minOccurs="0" />),因此你可以在 psalm.xml 中为它配置 <issueHandlers> 级别的抑制策略;但鉴于其 ERROR_LEVEL = -1 的致命定位,抑制它通常只会掩盖真正的运行故障,建议仅用于过渡期基线管理。
小结
ConstantDeclarationInTrait在“trait 声明常量”且目标 PHP 版本 < 8.2.0 时触发,源码依据见 ClassLikeNodeScanner.php;- 触发后果是 PHP 编译期致命错误,
ERROR_LEVEL = -1、SHORTCODE = 315,定义于 ConstantDeclarationInTrait.php; - 判定依赖
analysis_php_version_id,其取值链路为<phpVersion>配置 / composer 平台约束 → ProjectAnalyzer::setPhpVersion() →Codebase; - 回归测试位于 tests/TraitTest.php,以
php_version => '8.1'精确复现。