
前言代码规范检查这件事失败的方式几乎总是同一种某天有人兴致勃勃装了工具跑一遍屏幕上刷出四千条错误团队看了一眼就再也没打开过。半年后composer.json里躺着一个没人执行的phpcs依赖提交历史里一半的 diff 是空格和引号。另一些团队则是走到了另一个极端装了三个工具phpcs、php-cs-fixer、phpstan全都配置成「可以改代码」。结果php-cs-fixer把数组短语法改回来phpcbf又改回去本地保存一次就产生一堆无关 diff最后工具被卸掉。这两类问题的根因都是同一个没有分清「检查」和「修复」是两种角色也没有分清「风格」和「类型」是两件事。规范的落地是一个流程设计问题不是装几个包的问题。本文以PHP 8.5环境为例把四件事一次配好谁负责改、谁负责查、谁是权威判定、以及怎么让存量项目从「一片红」平滑过渡到「全绿」。示例使用 PHP-CS-Fixer、PHP_CodeSnifferphpcs、PHPStan 三个最主流的工具全部通过 Composer 安装最低要求PHP 8.1工具链本身对 PHP 版本有要求以你安装的版本为准。一、先分清工具的角色这四个工具经常被混为一谈但它们的职责完全不同混用就会打架工具角色是否改代码管什么典型命令PHP-CS-Fixer风格修复者会缩进、短数组、严格类型、import 排序php-cs-fixer fixPHP_CodeSnifferphpcs风格检查者不会同上但只报不改phpcsPHP_CodeSnifferphpcbf风格修复者会修复phpcs报出的问题phpcbfPHPStan静态分析不会类型错误、未定义变量、不可达代码phpstan analyseLaravel PintPHP-CS-Fixer 的封装会Laravel 预设风格pint核心原则风格修复者只能有一个。两个修复者同时存在就等于两套规则同时生效它们的输出必然互相覆盖。本文的组合是PHP-CS-Fixer 负责改本地、pre-commit 钩子里跑phpcs 负责查CI 里跑规范与 PHP-CS-Fixer 保持同源即可避免规则冲突PHPStan 负责类型CI 里跑和风格完全无关。如果你的团队只能接受一个工具那就只用 PHP-CS-Fixer PHPStan把 phpcs 去掉——少一个工具比多一个互相打架的工具强。二、PHP-CS-Fixer唯一的修复者安装composer require --dev friendsofphp/php-cs-fixer配置文件放在项目根目录.php-cs-fixer.php?php declare(strict_types1); $finder PhpCsFixer\Finder::create() -in([__DIR__ . /src, __DIR__ . /tests]) -name(*.php) -ignoreDotFiles(true) -ignoreVCS(true); return (new PhpCsFixer\Config()) // risky 规则默认关闭必须显式允许才会生效 -setRiskyAllowed(true) -setUsingCache(true) -setCacheFile(__DIR__ . /var/.php-cs-fixer.cache) -setRules([ // 基线PSR-12 PSR12 true, // 迁移规则集把代码往新语法上推。PHP-CS-Fixer 提供 PHPXYMigration // 系列如 PHP80Migration你装的版本支持到哪一版 // 用 vendor/bin/php-cs-fixer describe PHP80Migration 确认 PHP80Migration:risky true, // 风格细节 array_syntax [syntax short], trailing_comma_in_multiline [ elements [arrays, arguments, parameters], ], ordered_class_elements true, visibility_required [elements [property, method, const]], no_unused_imports true, global_namespace_import [ import_classes true, import_functions false, ], // 下面三条都是 risky它们会改变代码语义务必先跑测试 declare_strict_types true, strict_comparison true, strict_param true, ]) -setFinder($finder);这里要划三个重点setRiskyAllowed(true)是必须的开关。PHP-CS-Fixer 把「会改变运行时行为」的规则declare_strict_types、strict_comparison、strict_param归为 risky 类默认不生效。不开这个开关你会觉得规则明明配了却没反应。risky 规则一定要先有测试覆盖。strict_comparison会把改成在1 1这类场景下语义直接变了。规范工具改坏业务的案例基本都是这一条。不要照抄网上的「最严规则集」。.php-cs-fixer.php是团队契约宁可少列几条也不要列了之后天天// phpcs:ignore。日常命令# 本地直接改改完记得 review vendor/bin/php-cs-fixer fix # CI只检查不改 vendor/bin/php-cs-fixer fix --dry-run --diff --using-cacheno--dry-run在有问题时返回非零退出码CI 直接就能用它当门槛。--using-cacheno是为了防止 CI 复用了上次运行的缓存导致改动的文件被跳过——这个坑很隐蔽会出现「本地报错、CI 不报」的诡异现象。三、检查层phpcs 与 PHPStan3.1 phpcs只查不改的那一层安装并生成初始配置composer require --dev squizlabs/php_codesnifferphpcs.xml?xml version1.0? ruleset nameapp-standard description项目编码规范只检查不修复/description filesrc/file filetests/file !-- 生成代码、第三方代码一律不查 -- exclude-pattern*/vendor/*/exclude-pattern exclude-pattern*/var/*/exclude-pattern exclude-pattern*/storage/*/exclude-pattern arg namebasepath value./ arg namecolors/ arg nameextensions valuephp/ arg nameparallel value8/ arg namecache valuevar/.phpcs.cache/ !-- 基线标准 -- rule refPSR12 !-- 入口文件必然有副作用这条对 index.php 之类的文件不适用 -- exclude namePSR1.Files.SideEffects/ /rule !-- 明确禁止长数组语法短语法交给 PHP-CS-Fixer 修 -- rule refGeneric.Arrays.DisallowLongArraySyntax/ !-- 行尾多余空白 -- rule refSquiz.WhiteSpace.SuperfluousWhitespace/ /ruleset注意PSR12里已经包含了行宽相关的检查软限制 120 字符、无硬限制。如果团队想改成别的长度用下面的方式显式覆盖而不是在别处再写一条冲突的规则rule refGeneric.Files.LineLength properties property namelineLimit value120/ property nameabsoluteLineLimit value0/ /properties /rule运行vendor/bin/phpcs # 检查 vendor/bin/phpcs --reportsummary # 只看汇总适合第一次跑存量项目强烈建议第一次跑存量项目时加--reportsummary。直接看几千条详单会让人当场放弃而汇总报告只告诉你「哪一条规则犯了多少次」从最高频的那几条开始治理体感完全不同。如果团队想要更细的规则命名、类型声明的严格程度、禁止某些写法可以引入第三方标准例如slevomat/coding-standard然后在 ruleset 里用一条rule节点把SlevomatCodingStandard整组引入。引入前先确认它的 PHP 版本要求与你的环境匹配。3.2 PHPStan和风格无关的另一半composer require --dev phpstan/phpstanphpstan.neonparameters: level: 8 paths: - src - tests excludePaths: - src/legacy/* - tests/fixtures/* tmpDir: var/phpstan # 不要把 PHPDoc 的类型当成绝对可信避免误报压制真实问题 treatPhpDocTypesAsCertain: false checkMissingIterableValueType: falsePHPStan 的价值和风格检查完全不同它能在不运行代码的前提下发现「这个变量可能是 null」「这里传了 string 但方法要 int」「这个catch永远抓不到」。等级从 0 到 8 递增不要一上来就 level 8。正确姿势是先生成基线baseline把存量问题冻结然后让 CI 只对新增代码负责vendor/bin/phpstan analyse --generate-baseline生成phpstan-baseline.neon之后在配置里引入它includes: - phpstan-baseline.neon以后每次想收紧等级或新增规则删掉基线里对应的条目即可问题会一个个浮出来而不是一次性砸在脸上。四、串起来Composer 脚本 CI 提交钩子composer.json里定义统一入口让所有人用同一条命令{ scripts: { fix: php-cs-fixer fix, lint: php-cs-fixer fix --dry-run --diff, cs: phpcs -q, analyse: phpstan analyse --no-progress, check: [lint, cs, analyse] } }Composer 执行脚本时会把vendor/bin加进PATH所以不用写全路径。开发者本地只需要记住两条composer fix改、composer check查。CI 里把它当权威判定name: code-quality on: pull_request: push: branches: [main] jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: shivammathur/setup-phpv2 with: php-version: 8.5 tools: cs2pr coverage: none - run: composer install --no-interaction --prefer-dist --no-progress - name: 风格检查只检查不修改 run: vendor/bin/php-cs-fixer fix --dry-run --diff --using-cacheno - name: phpcscheckstyle 输出转成 PR 行内评论 if: always() run: vendor/bin/phpcs -q --reportcheckstyle | cs2pr - name: 静态分析 if: always() run: vendor/bin/phpstan analyse --no-progress --error-formatgithubif: always()保证风格检查失败时静态分析仍然会跑完——一次 PR 把所有问题都暴露出来而不是修一个跑一次。本地快反馈用 Git 钩子只检查本次提交的文件#!/usr/bin/env bash # 保存为 .git/hooks/pre-commit 并 chmod x set -euo pipefail mapfile -t files (git diff --cached --name-only --diff-filterACM -- *.php) [[ ${#files[]} -eq 0 ]] exit 0 # --path-modeintersection只处理传入的这些文件 vendor/bin/php-cs-fixer fix --using-cacheno --path-modeintersection ${files[]} # 把修复后的内容放回暂存区 git add -- ${files[]} # 再查一遍风格之外的问题比如行尾空白需要人工处理 vendor/bin/phpcs -q ${files[]}如果团队人多把钩子脚本放进仓库比如tools/hooks/pre-commit再用git config core.hooksPath tools/hooks统一启用比让每个人手动拷进.git/hooks靠谱得多。常见坑点❌ 同时把phpcbf和php-cs-fixer fix配成可执行两者规则不一致互相覆盖✅ 只保留一个「修复者」另一个只做检查本文的组合是 PHP-CS-Fixer 改、phpcs 查❌ 配了declare_strict_types、strict_comparison但没开setRiskyAllowed(true)规则静默不生效✅ 显式-setRiskyAllowed(true)并且为这些语义变更准备测试❌ CI 里跑php-cs-fixer fix不加--dry-runCI 改了工作区的文件但不提交白跑一遍还报成功✅ CI 只允许--dry-run --diff真正修改只在开发者本地或 pre-commit 里发生❌ CI 复用了缓存文件--using-cache默认开启上一次的检查结果被跳过✅ CI 里统一加--using-cacheno或把--cache-file指到每次都不同的临时路径❌ 一上来就level: 8 打开全部 phpcs 规则CI 立刻红成一片两周后整个检查被注释掉✅ 存量项目先--reportsummary看清高频问题PHPStan 先--generate-baseline再逐步收紧❌ 只靠 pre-commit 钩子把关团队成员用git commit --no-verify一行就绕过去了✅ pre-commit 定位为「快速反馈」权威判定必须放在 CI 上且 CI 是必过检查❌ 规则配置里同时写PSR12和一条行宽 80 的规则工具报出互相矛盾的问题✅ 行宽只在一个地方定义覆盖Generic.Files.LineLength的属性不要重复声明❌ 检查范围没排除vendor/、生成代码、缓存目录跑一次要几分钟✅exclude-pattern与Finder::exclude()双管齐下把生成代码显式排除❌ 编辑器开着 format-on-save用的却是另一套缩进规则保存一次就产生一堆无关 diff✅ 让编辑器复用同一份.php-cs-fixer.php或者关掉自动格式化统一由composer fix处理总结关注点选谁在哪跑关键参数风格修复PHP-CS-Fixer本地 / pre-commitfix风格检查PHP-CS-FixerCI权威--dry-run --diff --using-cacheno规范审计phpcsCI-q --reportcheckstyle、--reportsummary看存量类型与静态分析PHPStanCI--generate-baseline起步逐级升到 8统一入口Composer scripts本地composer fix/composer check快反馈Git pre-commit本地只处理git diff --cached的文件配置规范检查工具的技术难度几乎为零难的是流程设计修复者只留一个、检查者放在 CI、存量问题用 baseline 冻结、新代码用增量门槛卡住。把这四件事定下来团队就不会再经历「装了一堆工具然后全部关闭」的循环。工具本身在 PHP 8.5 上跑没有任何特殊要求真正需要留意的是工具链版本与 PHP 版本要匹配——升级 PHP 时顺手跑一次composer update并确认工具仍能正常执行即可。