PSR-12+PHPStan+ECS:PHP代码规范落地实战
发布日期: 2026/08/06 阅读总量: 1

先说一个真实的烂摊子

年初接手了一个维护了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_CodeSnifferECS (EasyCodingStandard)
版本3.11.112.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万行代码。

指标接入前接入后第一周
代码风格自动修复文件数0342个(包含重复运行)
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->statuswhere()返回的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会炸出几百个错误,团队根本修不完。我们的落地路径:

  1. 第一周:只跑ECS。先把代码风格统一,这步机械,让团队花两天熟悉规则。
  2. 第二周:PHPStan level 5,只扫app/目录。level 5检查未定义变量和参数类型,实用但没到强迫症程度。
  3. 第四周:升到level 8,同时要求新代码提交必须过。
  4. 第六周:历史问题清零,把老的报错分配给各业务owner修。

进度报表:第六周结束时,level 8报错从127降到了23,那23个集中在两个历史库里,明确写进ignoreErrors里并挂TODO。

再多说一点:git blame与代码规范的关系

有人觉得强制格式化会导致git blame被清洗,很难追责。这是个真实矛盾。我的看法是:代码规范的价值远大于blame的准确性。而且ECS运行在pre-commit阶段,会在commit前就格式化,不会产生“格式化专提交”。只要大家都用同一套配置,blame记录了代码变更逻辑的正确性,格式变化是机械操作,不影响人看diff。

最后给套参考资料

这套方案在团队跑了3个月,最大的收获不是“代码变好看了”,而是code review的讨论从“你这里少了个空格”变成了“这个类型为什么是mixed”,这才是工具该有的意义。