在 PHP 项目从快速迭代走向长期维护的过程中,代码质量往往是最先失控的维度。单元测试能捕获逻辑错误,却无法发现类型不匹配、未定义方法调用、死代码路径等结构性问题。PHP 作为一门动态语言,类型系统直到 7.x 才逐步完善,大量遗留代码仍然缺乏类型声明。静态分析工具(Static Analysis)正是在这个缺口上补位的关键基础设施——它在不运行代码的前提下,通过数据流分析和类型推导发现潜在缺陷,将 Bug 扼杀在提交之前。
本文将系统性地介绍 PHP 生态中两大静态分析引擎——PHPStan 和 Psalm——的核心原理、配置实践与进阶用法,并结合架构级 lint 工具(如 Deptrac、PHPMD)构建一套从方法级到模块级的分层质量守护体系。无论你是维护百万行遗留系统的架构师,还是在新项目中追求零 Bug 提交的工程师,都能从中找到可落地的实践方案。

一、为什么 PHP 项目需要静态分析
动态语言的灵活性是双刃剑。PHP 允许你在运行时修改对象属性、调用不存在的方法(通过 __call 魔术方法)、混合传递不同类型的参数。这种自由度在原型阶段是优势,在规模化阶段是灾难。以下几类问题在 PHP 项目中极为常见,却无法被测试轻易捕获:
- 类型不匹配:函数期望 int,传入 string;数组结构不符合预期
- 空值引用:对可能为 null 的变量调用方法,导致 Call to a member function on null
- 未定义符号:拼写错误的方法名、已删除的类常量、不存在的 Trait
- 死代码:永远不会执行的分支、未使用的 import 和私有方法
- 架构违规:Controller 直接调用 DAO、跨模块的隐式依赖
静态分析工具通过模拟程序执行路径(符号执行)和推导变量类型(类型推导),在不运行代码的情况下识别上述问题。根据 PHPStan 官方统计,在 level 5(中等严格度)下,典型 PHP 项目中每 1000 行代码能发现 3-8 个潜在缺陷——其中相当比例是运行时偶发的空值错误和类型错误。
二、PHPStan 核心原理与配置实战
PHPStan 由 Ondřej Mirtes 创建,是 PHP 生态中用户量最大的静态分析引擎。它的核心思想是渐进式严格:从 level 0(基本检查)到 level 9(最严格),你可以逐步提升分析强度,避免一次性面对数百个误报。
2.1 安装与基础配置
通过 Composer 安装 PHPStan:
1
2
3
4
5 # 项目本地安装(推荐)
composer require --dev phpstan/phpstan
# 验证安装
vendor/bin/phpstan --version
在项目根目录创建配置文件 phpstan.neon:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 parameters:
level: 5
paths:
- src
- tests
excludePaths:
- src/Legacy/*
scanDirectories:
- vendor/some/package/src
universalObjectCratesClasses:
- stdClass
- SimpleXMLElement
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
关键配置项说明:
| 参数 | 作用 | 推荐值 |
|---|---|---|
| level | 分析严格度,0-9 | 新项目从 5 开始,遗留项目从 1 开始 |
| paths | 扫描目录 | 通常为 src 和 tests |
| excludePaths | 排除目录 | 遗留代码、生成代码 |
| universalObjectCratesClasses | 标记为任意属性均可的类 | stdClass 等 |
| checkMissingIterableValueType | 要求迭代器有类型声明 | true |
2.2 分析级别详解与升级策略
PHPStan 的 10 个级别对应不同的分析深度。理解每个级别的检查内容是制定升级策略的基础:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 # Level 0-1: 基础存在性检查
# - 未定义的类、方法、函数、常量
# - 调用不存在的方法(无 __call 魔术方法时)
# Level 2-3: 类型声明检查
# - 验证参数类型与声明是否一致
# - 检查返回值类型是否匹配
# Level 4-5: 空值安全与混合类型
# - 可能的 null 调用
# - mixed 类型的操作(字符串拼接数组等)
# - 未检查的迭代器类型
# Level 6-7: 严格类型推导
# - 变量类型在分支中可能变化
# - 泛型类型参数验证
# - 闭包参数与返回值推导
# Level 8-9: 最严格
# - 所有代码必须有完整类型声明
# - 不允许任何 mixed
# - 死代码检测
升级策略:在 CI 中设置 level: max(即当前能通过的最高级别),然后逐步提升。每提升一级,修复新出现的问题后再升级下一级。一个实用的 CI 配置:
1
2
3
4
5
6
7
8
9
10 # .github/workflows/static-analysis.yml
name: Static Analysis
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/phpstan analyse --no-progress --error-format=github
2.3 处理误报:baseline 与忽略
遗留项目中首次运行 PHPStan 往往产出上千条错误。baseline 机制允许你冻结当前错误,只关注新增错误:
1
2
3
4
5
6
7
8
9
10
11 # 生成 baseline 文件
vendor/bin/phpstan analyse --generate-baseline
# 这会在项目根目录生成 phpstan-baseline.neon
# 内容示例:
parameters:
ignoreErrors:
-
message: '#^Call to an undefined method#'
path: src/Legacy/OldService.php
count: 12
对于单个需要忽略的错误行,可以使用 PHPDoc 注解:
1
2
3
4
5
6
7
8 class UserService
{
public function findUser(int $id): ?User
{
// @phpstan-ignore return.type
return $this->legacyLookup($id);
}
}
注意:baseline 是过渡工具,不是长期方案。最佳实践是每月清理一批 baseline 错误,逐步减少总数。
三、Psalm 核心原理与独特能力
Psalm 由 Vimeo/Mashape 团队开发,与 PHPStan 并列为 PHP 生态的两大静态分析引擎。两者共享绝大多数检查能力,但 Psalm 在以下几个领域有独特优势:
- Taint 分析:追踪用户输入到敏感操作的数据流,发现注入漏洞
- 更精细的泛型支持:对复杂泛型有更好的推导
- 自动修复:–alter 模式可以自动添加类型声明
3.1 安装与配置
1
2
3
4
5
6 composer require --dev vimeo/psalm
# 初始化配置
vendor/bin/psalm --init
# 生成 psalm.xml 配置文件
典型 psalm.xml 配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 <?xml version="1.0"?>
<psalm
errorLevel="5"
resolveFromConfigFile="true"
findUnusedCode="true"
findUnusedVariablesAndParams="true"
findUnusedPsalmSuppress="true"
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<directory name="src/Legacy" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<PossiblyNullReference errorType="suppress" />
</issueHandlers>
</psalm>
3.2 Taint 分析:SQL 注入与 XSS 防护
Psalm 的 Taint 分析是其最有价值的独特功能。启用后在 CI 中可以自动检测注入漏洞:
1
2
3
4
5 # 启用 taint 分析
vendor/bin/psalm --taint-analysis
# 或在 psalm.xml 中配置
# <psalm taintAnalysis="true">
以下代码会被 Psalm 标记为安全漏洞:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 class UserRepository
{
public function findByEmail(PDO $db, string $email): ?array
{
// Psalm Taint: Detected tainted SQL
// $_GET['email'] -> $email -> 直接拼入 SQL
$sql = "SELECT * FROM users WHERE email = '" . $email . "'";
return $db->query($sql)->fetch();
}
// 修复:参数化查询
public function findByEmailSafe(PDO $db, string $email): ?array
{
$stmt = $db->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
return $stmt->fetch();
}
}
Taint 分析会追踪数据从源头(source)到汇(sink)的完整路径:
| 类型 | Source(源头) | Sink(汇) |
|---|---|---|
| SQL 注入 | $_GET, $_POST, $_REQUEST | PDO::query(), mysqli::query() |
| XSS | $_GET, $_POST | echo, htmlspecialchars()(配置错误时) |
| 命令注入 | $_GET, $_POST | exec(), system(), shell_exec() |
| 路径遍历 | $_GET, $_POST | fopen(), file_get_contents(), include |
3.3 自动修复:类型声明补全
1
2
3
4
5
6
7
8 # 自动添加缺失的类型声明(安全模式,需确认)
vendor/bin/psalm --alter --issues=all
# 仅修复特定问题类型
vendor/bin/psalm --alter --issues=MissingParamType,MissingReturnType
# 预览但不修改(dry-run)
vendor/bin/psalm --alter --issues=all --dry-run
自动修复对于大规模遗留项目的类型声明补全非常高效。一次 –alter 运行可以为数千个方法签名添加类型声明,比手动操作效率高两个数量级。建议在独立的 Git 分支上运行,review 后再合并。
四、PHPStan vs Psalm:如何选择
两个引擎各有优势,选择取决于项目需求:
| 维度 | PHPStan | Psalm |
|---|---|---|
| 社区规模 | 更大(Composer 下载量约 3x) | 较小但活跃 |
| Taint 分析 | 不支持 | 核心优势 |
| 泛型支持 | 优秀 | 更精细 |
| 自动修复 | 不支持 | –alter 模式 |
| 框架扩展 | 更多(Laravel, Symfony, Doctrine) | 相对较少 |
| 运行速度 | 略快 | 略慢(增量分析弥补) |
| 误报率 | 中等 | 略低 |
| 学习曲线 | 较平缓 | 中等 |
推荐策略:
- Web 应用项目(有用户输入):优先 Psalm,利用 Taint 分析
- 库/SDK 开发:优先 PHPStan,社区覆盖面更广
- 大型遗留系统改造:先用 Psalm 的 –alter 补全类型,再用 PHPStan 的 baseline 渐进提升
- 安全敏感项目:两者并用,Psalm 负责安全扫描,PHPStan 负责类型完整性
五、架构级 lint:Deptrac 与 PHPMD
方法级别的类型安全只是代码质量的第一层。当项目规模超过 50 个模块后,架构层面的问题开始显现:循环依赖、跨层调用、职责混乱。这类问题不在 PHPStan/Psalm 的检测范围内——它们分析的是类型流,不是依赖流。
5.1 Deptrac:依赖规则引擎
Deptrac 定义模块之间的依赖方向,检测违规引用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 # depfile.yaml
layers:
- name: Controller
collectors:
- type: className
regex: .*Controller.*
- name: Service
collectors:
- type: className
regex: .*Service.*
- name: Repository
collectors:
- type: className
regex: .*Repository.*
ruleset:
Controller:
- Service # Controller 可以依赖 Service
Service:
- Repository # Service 可以依赖 Repository
Repository: [] # Repository 不能依赖任何其他层
1
2
3 # 安装与运行
composer require --dev qossmic/deptrac-shim
vendor/bin/deptrac analyse --config-file=depfile.yaml
运行后会输出类似结果:
1
2
3
4
5 Controller\UserController has a dependency on Repository\UserRepository
in src/Controller/UserController.php:42
Controller -> Repository (SKIPPED Service layer)
Expected: Controller -> Service -> Repository
5.2 PHPMD:代码复杂度检测
PHPMD(PHP Mess Detector)检测代码的复杂度与设计问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 <ruleset name="Custom PHPMD Rules">
<rule ref="rulesets/codesize.xml">
<exclude name="TooManyPublicMethods" />
</rule>
<rule ref="rulesets/codesize.xml/CyclomaticComplexity">
<properties>
<property name="reportLevel" value="10" />
</properties>
</rule>
<rule ref="rulesets/cleancode.xml">
<exclude name="StaticAccess" />
</rule>
<rule ref="rulesets/unusedcode.xml" />
<rule ref="rulesets/design.xml">
<exclude name="CouplingBetweenObjects" />
</rule>
</ruleset>
1
2 # 运行
vendor/bin/phpmd src xml ruleset.xml
PHPMD 能检测到的问题包括:圈复杂度超标(大于10)、过长参数列表、未使用的私有方法、深层嵌套等。这些坏味道虽不直接导致 Bug,却是技术债的可靠指标。
六、完整 CI 工作流:分层质量守护
将上述工具组合成一套分层的 CI 工作流,每层关注不同维度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64 # .github/workflows/quality-gate.yml
name: Quality Gate
on: [push, pull_request]
jobs:
# 第一层:快速反馈(小于30s)
syntax-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: php -l src/ # PHP 语法检查
# 第二层:静态类型分析(2-5min)
phpstan:
runs-on: ubuntu-latest
needs: syntax-check
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/phpstan analyse --no-progress --error-format=github
# 第三层:安全扫描(3-8min)
psalm-taint:
runs-on: ubuntu-latest
needs: syntax-check
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/psalm --taint-analysis
# 第四层:架构检查(1-3min)
deptrac:
runs-on: ubuntu-latest
needs: syntax-check
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/deptrac analyse --config-file=depfile.yaml
# 第五层:复杂度与设计(1-2min)
phpmd:
runs-on: ubuntu-latest
needs: syntax-check
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/phpmd src text ruleset.xml
# 质量门禁:所有层必须通过
quality-gate:
runs-on: ubuntu-latest
needs: [phpstan, psalm-taint, deptrac, phpmd]
if: always()
steps:
- name: Check all jobs passed
run: |
if [ "${{ needs.phpstan.result }}" != "success" ] ||
[ "${{ needs.psalm-taint.result }}" != "success" ] ||
[ "${{ needs.deptrac.result }}" != "success" ] ||
[ "${{ needs.phpmd.result }}" != "success" ]; then
echo "Quality gate failed"
exit 1
fi
echo "Quality gate passed"
七、遗留系统改造实战路线图
对于缺少类型声明、没有测试覆盖的老项目,全面引入静态分析是痛苦但必要的。以下是经过验证的改造路线图:
阶段一:建立基线(1-2 周)
- 安装 PHPStan level 1,运行并生成 baseline
- 在 CI 中运行 PHPStan,baseline 错误不计入失败
- 安装 Psalm(不启用 taint),生成 baseline
- 记录两个工具的错误基线数量,作为改造进度度量
阶段二:类型补全(2-4 周)
- 使用 Psalm –alter 自动添加参数和返回类型
- 在独立分支运行,review 后合并
- 升级 PHPStan 到 level 3-4
- 清理对应的 baseline 条目
阶段三:空值安全(2-3 周)
- 升级 PHPStan 到 level 5-6
- 系统性地添加可空标记和空值检查
- 引入 assert() 和 instanceof 类型缩小
- 消灭 Mixed 类型警告
阶段四:架构治理(持续)
- 引入 Deptrac,定义核心依赖规则
- 启用 Psalm Taint 分析
- 设置 PHPMD 圈复杂度阈值
- 每月提升 PHPStan 级别,直到达到 level 8
每个阶段的成功标准明确:baseline 错误数持续下降,PHPStan 级别持续上升。在改造过程中,CI 始终是绿色的——baseline 保护你免受新错误的困扰,同时确保代码质量持续改善。
八、常见陷阱与最佳实践
8.1 避免过度抑制
@phpstan-ignore 和 @psalm-suppress 是必要的过渡工具,但滥用会降低分析有效性。建议:
- 每次抑制必须附带原因注释:// @phpstan-ignore-next-line – Legacy API, refactoring in Q3
- 在 CI 中检测未使用的抑制(Psalm 的 findUnusedPsalmSuppress)
- 每季度审查所有抑制注解,移除不再需要的
8.2 增量分析加速
大型项目全量分析耗时可能超过 10 分钟。利用增量分析缩短反馈循环:
1
2
3
4
5
6
7
8 # PHPStan 增量分析(仅分析变更文件)
vendor/bin/phpstan analyse --no-progress --ansi
# Psalm 增量分析(缓存推导结果)
vendor/bin/psalm --no-cache=false # 默认启用缓存
# 只分析 Git 变更文件(适用于 pre-commit hook)
git diff --name-only --diff-filter=ACM HEAD | grep '\.php$' | xargs vendor/bin/phpstan analyse
8.3 pre-commit 集成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 # .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: phpstan
name: PHPStan
entry: vendor/bin/phpstan analyse --no-progress --memory-limit=512M
language: system
types: [php]
pass_filenames: false
- id: psalm
name: Psalm
entry: vendor/bin/psalm --no-cache --no-progress
language: system
types: [php]
pass_filenames: false
8.4 Composer scripts 统一入口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 # composer.json
{
"scripts": {
"analyse": [
"@phpstan",
"@psalm",
"@deptrac"
],
"phpstan": "vendor/bin/phpstan analyse --no-progress",
"psalm": "vendor/bin/psalm --no-cache",
"psalm-taint": "vendor/bin/psalm --taint-analysis",
"deptrac": "vendor/bin/deptrac analyse --config-file=depfile.yaml",
"phpmd": "vendor/bin/phpmd src text ruleset.xml"
}
}
统一入口的好处是开发者只需执行 composer analyse 即可运行所有检查,降低使用门槛。CI 和本地使用同一套命令,保证一致性。
结语
静态分析不是银弹,但它是 PHP 项目质量保障体系中性价比最高的投资。PHPStan 提供了渐进式类型安全升级路径,Psalm 带来了独特的安全漏洞检测能力,Deptrac 和 PHPMD 补位了架构级与设计级约束。当这四层防护在 CI 中协同工作,你获得的不仅是更少的 Bug——更是对代码变更的信心,和对架构演进的掌控力。
从今天开始:在项目中运行 composer require –dev phpstan/phpstan,创建 phpstan.neon,设置 level 1,在 CI 中跑起来。这是零成本的第一步,也是最长旅程的起点。
汤不热吧