先说一个真实的烂摊子
年初接手了一个维护了5年的PHP项目,CI只有一个webhook,提交代码自动部署到测试服务器。代码风格全凭个人习惯:有人用tab缩进、有人用4空格,有人写if (有空格有人没有。最离谱的是有个同事把$_POST['id']直接拼进SQL里,这类低级错误到上线前一天才被发现。
我接手后第一周做的第一件事:把代码规范强制工具跑起来,用工具保证下限,人只处理机器发现不了的问题。
要解决的实际问题
- 代码风格不统一:merge request里一半的diff是缩进和空格,浪费review时间。
- 没有静态检查:基础类型错误、未定义变量、可达性bug,跑到生产环境才炸。
- 人工review效率低:5个后端review 200行代码平均花30分钟,还漏检。
目标:提交代码时自动做风格修复 + 静态分析,不满足条件直接挡在CI。
方案对比:PHP_CodeSniffer vs ECS
工具选型时对比了几组方案。
| 对比维度 | PHP_CodeSniffer | ECS (EasyCodingStandard) |
|---|---|---|
| 版本 | 3.11.1 | 12.0.3 |
| PSR-12原生支持 | 支持,但规则零散 | 内置psr12规则集,一条搞定 |
| 代码自动修复 | phpcbf可修 | vendor/bin/ecs check --fix 可修 |
| 规则扩展 | 需要写XML或PHP自定义 | PHP数组配置,可直接组合任意规则 |
| 与PHPStan集成 | 无 | 无,但概念一致 |
| 项目活跃度 | 长期未大更新 | 稳定维护,配置更现代化 |
结论:ECS内部就是组合了PHP-CS-Fixer和PHP_CodeSniffer的规则,用一套配置统一管理。对团队来说,学一个配置比学两种强。所以选ECS。
静态分析选型:PHPStan
对比过Psalm和PHPStan,两者都能做静态分析。选PHPStan的原因是:
- Level 0到8分级严格,渐进式落地好推(老项目可以先从level 5开始)。
- 报错信息带文件名、行号和类型推导过程,修起来快。
- larastan等扩展成熟,框架项目可以直接用。
最终统一为:ECS管风格+PHPStan管正确性,两条流水线互不干扰。
完整实施步骤
环境:PHP 8.3.2 / Laravel 11 / MySQL 8.0.35
第一步:安装依赖
# 项目根目录执行
composer require --dev easy-coding-standard:12.0.3
composer require --dev phpstan/phpstan:1.11.1
composer require --dev phpstan/extension-installer:1.4.3
composer require --dev phpstan/phpstan-deprecation-rules:1.2.1
安装后对应composer.json的require-dev会多出这几个包。
第二步:ECS配置
新建ecs.php,放在项目根目录。
<?php
// ecs.php
declare(strict_types=1);
use PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer;
use PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer;
use PhpCsFixer\Fixer\FunctionNotation\NativeFunctionInvocationFixer;
use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer;
use PhpCsFixer\Fixer\Operator\ConcatSpaceFixer;
use Symplify\EasyCodingStandard\Config\ECSConfig;
return ECSConfig::configure()
->withSets([ECSConfig::SET_PSR_12])
// 按需加规则
->withRules([
NoUnusedImportsFixer::class,
ArraySyntaxFixer::class, // 数组统一用短语法 []
])
->withConfiguredRule(ConcatSpaceFixer::class, [
'spacing' => 'one', // 字符串拼接两边加空格
])
->withConfiguredRule(YodaStyleFixer::class, [
'always_move_variable' => false, // 不强制Yoda风格,但禁止变量在前常量在后的不对称写法
])
->withConfiguredRule(NativeFunctionInvocationFixer::class, [
'include' => ['@compiler_optimized'],
'scope' => 'namespaced',
])
// 要扫的目录
->withPaths([
__DIR__ . '/app',
__DIR__ . '/routes',
__DIR__ . '/config',
__DIR__ . '/tests',
])
// 跳过某些文件
->withSkip([
__DIR__ . '/app/helpers/legacy_functions.php',
])
// 缓存目录,放到var下面
->withCacheDirectory(__DIR__ . '/var/cache/ecs');
第三步:PHPStan配置
新建phpstan.neon。
includes:
- vendor/phpstan/extension-installer/extension_installer.php
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
parameters:
level: 8
paths:
- app
- routes
- config
- tests
tmpDir: var/cache/phpstan
reportUnmatchedIgnoredErrors: true
ignoreErrors:
# 这个老文件有历史原因,暂时忽略,TODO: 下季度重构
- '#Undefined variable \$_legacy_config#'
path: app/helpers/legacy_functions.php
注意:PHPStan是静态分析,不是语法检查。level 8是最高级别,会检查:未定义变量、方法参数类型不匹配、永远为false的条件、不可达代码、缺失返回类型等。
第四步:CI脚本(GitLab CI示例)
项目用的是GitLab,直接加到.gitlab-ci.yml
stages:
- quality
php-code-quality:
stage: quality
image: composer:2.7
script:
- composer install --no-interaction --prefer-dist --no-progress
- vendor/bin/ecs check --fix --no-progress-bar
- git diff --exit-code # 如果有文件被ECS改了,说明开发没跑格式化,直接挂掉
- vendor/bin/phpstan analyse --no-progress --memory-limit=1G
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- vendor/
- var/cache/ecs/
- var/cache/phpstan/
only:
- merge_requests
- main
tags:
- php8.3
这里用了git diff --exit-code的trick:ecs check --fix会直接改文件,如果工作区出现改动,说明开发没跑格式化。
第五步:本地Hook(pre-commit)
用.git/hooks/pre-commit,不强制队友记住命令。这个文件不随git提交,所以要在README里写安装脚本。
#!/usr/bin/env bash
# .git/hooks/pre-commit
# 安装方式:ln -s ../../pre-commit-format.sh .git/hooks/pre-commit
php_file_changed=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')
if [ -z "$php_file_changed" ]; then
exit 0
fi
echo "正在自动修复代码风格..."
vendor/bin/ecs check --fix --no-progress-bar $php_file_changed
echo "重新暂存被修复的文件..."
git add $php_file_changed
echo "运行PHPStan..."
vendor/bin/phpstan analyse --no-progress --memory-limit=1G $php_file_changed || exit 1
这个脚本有个小坑:如果PHPStan报错,需要git add后再提交,否则暂存区还是旧版本。实际使用时推荐直接把pre-commit脚本放到项目tools/目录,再提供一条安装命令。
第六步:配置PHPStorm(可选,强烈建议)
编辑器层面用PHPStorm的「File Watcher」实时跑格式化,开发时不用等到commit才发现问题。
# PHPStorm Settings -> Tools -> File Watchers -> + -> PHP ECS
Name: ECS Formatter
File type: PHP
Program: $ProjectFileDir$/vendor/bin/ecs
Arguments: check --fix --no-progress-bar $FilePath$
Working directory: $ProjectFileDir$
Show console: on error
效果数据
拿这个老项目做了基线测试,项目规模:127个PHP文件,约8.6万行代码。
| 指标 | 接入前 | 接入后第一周 |
|---|---|---|
| 代码风格自动修复文件数 | 0 | 342个(包含重复运行) |
| PHPStan level 8 报错数 | 未检查 | 127个真正bug |
| 人工review一个MR平均耗时 | 32分钟 | 18分钟 |
| CI运行时长(ECS+PHPStan) | 无规范检查 | 46秒 |
| 上线后一周内线上故障 | 4起 | 0起 |
127个bug的分类:
- 43个未定义变量(都是字符串拼SQL或模板变量),比如
foreach里面用$item写成了$_item。 - 29个方法参数/返回类型不匹配,改了上层调用没改实现。
- 22个永远为false的条件,实际上是变量类型写死导致的。
- 33个不可达代码或多余条件判断。
ECS自动修复的主要是:导入未使用的use语句(删掉)、单引号双引号统一、数组短语法、空格换行、排序use语句。
这127个bug里,至少3个会导致线上报错(向未定义数组键写入),1个会造成SQL查询错误。如果没接PHPStan,大概率又是上线前手动查半天。
原理深入:这套组合为什么有效
要理解ECS和PHPStan的分工,得看它们各自在PHP编译流程中的位置。
ECS:基于Token的纯文本修复
ECS内部调用PHP-CS-Fixer和PHP_CodeSniffer,核心是token_get_all()把PHP文件解析成token数组。它不关心类型、不关心方法是否有实现,只关注token流是否符合规则。比如array()是T_ARRAYtoken,[]是T_OPEN_SHORT_ARRAY,规则检查这个token序列是否匹配目标格式。所以ECS修复非常快,只改文本,不执行代码。
这也是为什么ECS能安全修复缩进、换行、引号,但不可能发现未定义变量——它根本没构建语法树。
PHPStan:抽象语法树 + 类型推导
PHPStan先通过php-parse库把代码解析成AST(抽象语法树),然后模拟PHP的执行流程,遍历所有可能的分支,记录变量的类型。它内部有一套类型系统:IntegerType、StringType、UnionType、MixedType等。比如看到$id = $_GET['id'],它把$id推导为string,后面如果你把它当数组用,就会报错。
level 8意味着把mixed类型当作不允许直接操作的类型,强制开发者明确类型。这正是老项目的痛——到处都是mixed,改一个方法参数要全局搜usage。
为什么组合使用
ECS保证代码长一个样,PHPStan保证代码不会写错。前者减少merge conflict和review噪音,后者直接兜底逻辑错误。两者都写进CI,才叫落地。
常见问题与避坑指南
以下坑都是我实际踩过的,按严重程度排序。
坑1:ECS自动修复把业务代码改了
那是我第一次在旧项目直接跑ecs check --fix,修复了400多个文件。结果有5个文件运行报错——因为原来的代码用了@unlink(),规则集里有一个NativeFunctionInvocationFixer,在全局命名空间下给所有PHP内置函数加反斜杠前缀\unlink()。这本身没错,但旧代码在字符串拼接里引用了函数名,比如$func = 'unlink'; $func($file);,改成\unlink后字符串内容变了,运行时直接找不到函数。
规避方法:第一轮先在CI里跑ecs check(不加--fix),手动review diff列表,按目录逐步放开。别一次全项目自动修复。
坑2:PHPStan报“Undefined variable”但实际有定义
常见于extract()、compact()、${$var}这类动态变量。
<?php
function foo() {
$data = ['name' => 'zhang', 'age' => 30];
extract($data); // PHPStan无法推导出 $name 和 $age
return $name;
}
PHPStan会报Undefined variable $name。这类报错不能直接ignore,最好重构。我的做法是:先加array_key_exists校验再访问,或者直接用$_data不要extract。
坑3:PHPStan对Laravel的魔术方法报错
Laravel模型的$model->status、where()返回的Eloquent Builder,都能被PHPStan正确识别。但如果用了Model::query()->where(...)->firstOrFail(),PHPStan 1.11对混合返回类型还是会报错。解决方案是安装larastan扩展,版本要对应:Laravel 11配larastan 2.x。
composer require --dev larastan/larastan:2.9
然后在phpstan.neon里加上includes: vendor/larastan/larastan/extension.neon。
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 8
paths:
- app
坑4:pre-commit脚本里跑PHPStan会让提交变得很慢
只跑要提交的.php文件,比扫全项目快很多。PHPStan启动就要0.5秒左右,分析100个文件约3秒,pre-commit只分析增量文件通常1秒内。但如果项目里对单个文件设置了很高的level,PHPStan要把这个文件的依赖都加载一遍,反而慢。建议pre-commit只跑ECS,PHPStan交给CI。
坑5:ECS缓存导致的“改了代码还是旧格式”
ECS默认缓存在var/cache/ecs,如果某次规则配置改了,它会自动失效。但如果你直接在测试环境升级ECS版本,可能出现老缓存不兼容问题。解决:升级ECS后清一下缓存。
rm -rf var/cache/ecs
vendor/bin/ecs check --fix
坑6:PHPStan memory limit
老项目代码量大,PHPStan默认内存上限128MB,很轻松就会爆。我碰到过Allowed memory size exhausted,后来CI脚本里加了--memory-limit=1G才稳定。本地开发建议直接--memory-limit=-1(不限制),但CI里不要用,防止失控。
坑7:第三方代码不兼容PSR-12
vendor目录不要扫,只扫项目自己的代码。还有tests/和app/分开配规则,测试代码有时用@dataProvider,格式上有特殊要求,建议单独建一个ecs-test.php配置。
渐进式落地策略
老项目直接上level 8会炸出几百个错误,团队根本修不完。我们的落地路径:
- 第一周:只跑ECS。先把代码风格统一,这步机械,让团队花两天熟悉规则。
- 第二周:PHPStan level 5,只扫
app/目录。level 5检查未定义变量和参数类型,实用但没到强迫症程度。 - 第四周:升到level 8,同时要求新代码提交必须过。
- 第六周:历史问题清零,把老的报错分配给各业务owner修。
进度报表:第六周结束时,level 8报错从127降到了23,那23个集中在两个历史库里,明确写进ignoreErrors里并挂TODO。
再多说一点:git blame与代码规范的关系
有人觉得强制格式化会导致git blame被清洗,很难追责。这是个真实矛盾。我的看法是:代码规范的价值远大于blame的准确性。而且ECS运行在pre-commit阶段,会在commit前就格式化,不会产生“格式化专提交”。只要大家都用同一套配置,blame记录了代码变更逻辑的正确性,格式变化是机械操作,不影响人看diff。
最后给套参考资料
- PSR-12完整规范:https://www.php-fig.org/psr/psr-12/
- ECS文档:https://github.com/easy-coding-standard/easy-coding-standard
- PHPStan规则参考:https://phpstan.org/writing-php-code/phpstan-basics
这套方案在团队跑了3个月,最大的收获不是“代码变好看了”,而是code review的讨论从“你这里少了个空格”变成了“这个类型为什么是mixed”,这才是工具该有的意义。