PHPStan和Psalm—查找php错误的静态代码分析工具

张开发
2026/4/17 21:50:15 15 分钟阅读

分享文章

PHPStan和Psalm—查找php错误的静态代码分析工具
说起来有点丢人我以前特别讨厌静态分析觉得就是瞎折腾。直到有一次PHPStan 救了我一命差点让我丢了饭碗的那种救命。当时我给支付功能写了一段代码自己觉得写得挺好手工测试也过了单元测试也绿了看起来没毛病。结果同事非要我跑一下 PHPStan我心想这不是多此一举吗没想到一跑就炸了发现了一个类型错误这玩意儿会让支付金额算错就这么一个 bug彻底改变了我的想法。以前觉得 IDE 里那些红色波浪线烦死了现在觉得它们就是代码的保镖。现在让我不用静态分析写 PHP就像让我不系安全带开车一样心慌。静态分析到底有啥用不只是抓错字那次支付的事儿让我想明白了静态分析不是用来抓拼写错误的而是用来抓那些你自己看不出来的逻辑问题。写代码的时候你脑子里想的都是正常情况PHPStan 想的是各种能出错的地方。静态分析就像个特别较真的代码审查员什么都要质疑一遍。类型对不上、空指针、死代码这些问题它都能揪出来。就好比有个强迫症同事专门盯着你累了或者飘了的时候写的烂代码。PHPStan和Psalm定位与特性‌PHPStan‌采用NEON配置文件支持规则级别自定义如level: 8表示严格模式‌1提供多环境配置能力可通过--release参数指定PHP版本兼容性检查‌2典型配置示例12345level: 8paths: [src/, tests/]ignoreErrors: [{message: Undefined method call, count: 3}]‌Psalm‌使用XML配置文件.psalm.xml支持类型推断和PSR标准检查‌3内置对PHP 8新特性的支持如??运算符版本兼容性检测‌2基础配置示例123456psalm.xmlprojectnameMyApp /nameautoloadervendor/autoload.php /autoloader /project /psalm.xmlPHPStan我的编程好帮手自从那次支付的事儿之后PHPStan 就成了我写代码的标配。一开始是被逼着用的后来发现这玩意儿真香。最牛的地方是它懂 LaravelEloquent 关系、中间件这些 Laravel 的黑魔法它都认识别的工具经常搞不定。第一次跑 PHPStan 的时候我差点崩溃——我以为挺干净的代码库居然报了 847 个错误。不过修这些错误的过程中我学到的 PHP 类型安全知识比之前几年加起来都多。安装和基本设置12345# 安装 PHPStancomposer require --dev phpstan/phpstan# 创建 phpstan.neon 配置文件touchphpstan.neon12345678910111213# phpstan.neonparameters:level: 5paths:- app- testsexcludePaths:- app/Console/Kernel.php- app/Http/Kernel.phpcheckMissingIterableValueType: falsecheckGenericClassInNonGenericObjectType: falseignoreErrors:- #Unsafe usage of new static#分析级别从 0 到 8 的血泪史PHPStan 有 10 个级别这玩意儿教会了我什么叫循序渐进。一开始我想装逼直接跳到级别 9想证明自己是个严肃的开发者。结果级别 3 就把我整懵了2000 多个错误差点让我怀疑人生。后来我老实了按部就班来12345678# 级别 0 - 基本检查vendor/bin/phpstan analyze --level0# 级别 5 - 严格性和实用性的良好平衡vendor/bin/phpstan analyze --level5# 级别 9 - 非常严格几乎捕获所有问题vendor/bin/phpstan analyze --level9Laravel 集成12# 安装 Laravel 扩展composer require --dev nunomaduro/larastan123456789# 为 Laravel 更新的 phpstan.neon# 更多 Laravel 特定配置请参见# https://mycuriosity.blog/level-up-your-laravel-validation-advanced-tips-tricksparameters:level: 5paths:- appincludes:- ./vendor/nunomaduro/larastan/extension.neon高级 PHPStan 配置123456789101112131415161718192021222324252627# phpstan.neonparameters:level: 6paths:- app- tests# 忽略特定模式ignoreErrors:-#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#-#Method App\\Models\\User::find\(\) should return App\\Models\\User\|null but returns Illuminate\\Database\\Eloquent\\Model\|null## 自定义规则rules:- PHPStan\Rules\Classes\UnusedConstructorParametersRule- PHPStan\Rules\DeadCode\UnusedPrivateMethodRule- PHPStan\Rules\DeadCode\UnusedPrivatePropertyRule# 类型别名typeAliases:UserId:int1, maxEmail:string# 前沿功能reportUnmatchedIgnoredErrors: truecheckTooWideReturnTypesInProtectedAndPublicMethods: truecheckUninitializedProperties: truePsalm另一个强大的选择Psalm 是另一个优秀的静态分析工具有着不同的优势。它特别擅长发现复杂的类型问题并且有出色的泛型支持。安装和设置12345# 安装 Psalmcomposerrequire--dev vimeo/psalm# 初始化 Psalmvendor/bin/psalm --init12345678910111213141516171819202122232425262728!-- psalm.xml --?xml version1.0?psalmerrorLevel3resolveFromConfigFiletruexmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexmlnshttps://getpsalm.org/schema/configxsi:schemaLocationhttps://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsdprojectFilesdirectory nameapp/directory nametests/ignoreFilesdirectory namevendor/file nameapp/Console/Kernel.php//ignoreFiles/projectFilesissueHandlersLessSpecificReturnType errorLevelinfo/MoreSpecificReturnType errorLevelinfo/PropertyNotSetInConstructor errorLevelinfo//issueHandlerspluginspluginClassclassPsalm\LaravelPlugin\Plugin//plugins/psalmPsalm 的 Laravel 插件12345# 安装 Laravel 插件composerrequire--dev psalm/plugin-laravel# 启用插件vendor/bin/psalm-plugin enable psalm/plugin-laravel血的教训那些差点要命的 Bug类型错误 - 差点出大事的支付 Bug就是下面这种写法当时我在算购物车总价想当然地以为数组里都是数字。PHPStan 一眼就看出来了数组里可能有各种乱七八糟的类型这要是上线了支付金额算错了还得了1234567891011121314151617181920212223// 我原来的危险代码functioncalculateTotal(array$items): float{$total 0;foreach($itemsas$item) {$total$item;// PHPStan: Cannot add array|string to int}return$total;// 可能返回完全错误的金额}// PHPStan 强制我明确类型functioncalculateTotal(array$items): float{$total 0.0;foreach($itemsas$item) {if(is_numeric($item)) {$total (float)$item;}else{thrownewInvalidArgumentException(All items must be numeric);}}return$total;}空指针问题1234567891011121314151617181920212223// PHPStan 捕获潜在的空指针functiongetUserEmail(int$userId): string{$user User::find($userId);// 返回 User|nullreturn$user-email;// 错误无法访问 null 上的属性}// 修复版本functiongetUserEmail(int$userId): ?string{$user User::find($userId);return$user?-email;}// 或者显式空值检查functiongetUserEmail(int$userId): string{$user User::find($userId);if($user null) {thrownewUserNotFoundException(User {$userId} not found);}return$user-email;}无法到达的代码1234567891011121314// PHPStan 检测无法到达的代码functionprocessPayment(float$amount): bool{if($amount 0) {returnfalse;}if($amount 1000000) {thrownewInvalidArgumentException(Amount too large);}returntrue;echoPayment processed;// 无法到达的代码}高级类型注解泛型类型123456789101112/*** template T* param class-stringT $className* return T*/functioncreateInstance(string$className): object{returnnew$className();}// 使用$user createInstance(User::class);// PHPStan 知道这是 User集合类型1234567891011121314151617/*** param arrayint, User $users* return arrayint, string*/functionextractUserEmails(array$users):array{returnarray_map(fn(User$user) $user-email,$users);}/*** param Collectionint, Product $products* return Collectionint, Product*/functiongetActiveProducts(Collection$products): Collection{return$products-filter(fn(Product$product) $product-isActive());}复杂类型定义1234567891011121314151617/*** param array{name: string, age: int, email: string} $userData* return User*/functioncreateUser(array$userData): User{returnnewUser($userData[name],$userData[age],$userData[email]);}/*** param arraystring, int|string|bool $config* return void*/functionconfigure(array$config): void{// 实现}自定义 PHPStan 规则为你的特定需求创建自定义规则12345678910111213141516171819202122232425// CustomRule.phpusePHPStan\Rules\Rule;usePHPStan\Analyser\Scope;usePhpParser\Node;classNoDirectDatabaseQueryRuleimplementsRule{publicfunctiongetNodeType(): string{returnNode\Expr\StaticCall::class;}publicfunctionprocessNode(Node$node, Scope$scope):array{if($node-classinstanceofNode\Name $node-class-toString() DB$node-nameinstanceofNode\Identifier in_array($node-name-name, [select,insert,update,delete])) {return[Direct database queries are not allowed. Use repositories instead.];}return[];}}与 CI/CD 集成GitHub Actions12345678910111213141516171819202122232425# .github/workflows/static-analysis.ymlname: Static Analysison: [push, pull_request]jobs:phpstan:runs-on: ubuntu-lateststeps:- uses: actions/checkoutv2- name: Setup PHPuses: shivammathur/setup-phpv2with:php-version:8.2- name: Install dependenciesrun: composer install --no-dev --optimize-autoloader- name: Run PHPStanrun: vendor/bin/phpstan analyze --error-formatgithub- name: Run Psalmrun: vendor/bin/psalm --output-formatgithubPre-commit 钩子12# 安装 pre-commitpip install pre-commit1234567891011121314151617# .pre-commit-config.yamlrepos:- repo: localhooks:- id: phpstanname: phpstanentry: vendor/bin/phpstan analyze --no-progresslanguage: systemtypes: [php]pass_filenames: false- id: psalmname: psalmentry: vendor/bin/psalm --no-progresslanguage: systemtypes: [php]pass_filenames: false代码质量工具集成PHP CS Fixer12# 安装 PHP CS Fixercomposerrequire--dev friendsofphp/php-cs-fixer1234567891011121314151617# .php-cs-fixer.php?phpreturn(newPhpCsFixer\Config())-setRules([PSR12 true,array_syntax [syntaxshort],ordered_imports true,no_unused_imports true,declare_strict_types true,])// 遵循 PSR 标准提高代码质量// https://mycuriosity.blog/php-psr-standards-writing-interoperable-code-setFinder(PhpCsFixer\Finder::create()-in(app)-in(tests));PHPMD (PHP Mess Detector)12# 安装 PHPMDcomposerrequire--dev phpmd/phpmd123456789101112# phpmd.xml?xml version1.0?ruleset nameCustom PHPMD rulesetrule refrulesets/cleancode.xmlexclude nameStaticAccess//rulerule refrulesets/codesize.xml/rule refrulesets/controversial.xml/rule refrulesets/design.xml/rule refrulesets/naming.xml/rule refrulesets/unusedcode.xml//ruleset性能优化静态分析在大型代码库上可能很慢。以下是优化方法基线文件1234# 生成基线以忽略现有问题vendor/bin/phpstan analyze --generate-baseline# 这会创建 phpstan-baseline.neon123parameters:includes:- phpstan-baseline.neon并行处理12345# phpstan.neonparameters:parallel:maximumNumberOfProcesses: 4processTimeout: 120.0结果缓存1234# phpstan.neonparameters:tmpDir:var/cache/phpstanresultCachePath:var/cache/phpstan/resultCache.phpIDE 集成PHPStormPHPStorm 对 PHPStan 和 Psalm 都有出色的内置支持转到 Settings PHP Quality Tools配置 PHPStan 和 Psalm 路径在 Editor Inspections 中启用检查VS Code12345678// .vscode/settings.json{php.validate.enable: false,php.suggest.basic: false,phpstan.enabled: true,phpstan.path:vendor/bin/phpstan,phpstan.config:phpstan.neon}实际实施策略 - 团队采用的经验教训让我的团队采用静态分析比我自己学习它更困难。开发者讨厌被告知他们的代码有 800 个错误特别是当它运行得很好的时候。以下是真正有效的方法遵循清洁代码原则以获得更好的团队采用第一阶段基础第 1-2 周在级别 0 安装 PHPStan修复基本问题设置 CI/CD 集成第二阶段渐进改进第 3-4 周提升到级别 3添加 Laravel/框架特定规则培训团队注解第三阶段高级功能第 5-6 周达到级别 5-6添加自定义规则为遗留代码实施基线第四阶段精通持续进行新代码达到级别 8-9添加 Psalm 以获得额外覆盖持续改进常见陷阱和解决方案过度抑制1234567// 不好 - 抑制过于宽泛/** phpstan-ignore-next-line */$user User::find($id);// 好 - 具体抑制并说明原因/** phpstan-ignore-next-line User::find() can return null but we know ID exists */$user User::find($validatedId);类型注解过载1234567// 不好 - 过度注解明显类型/** var string $name */$nameJohn;// 好 - 注解复杂类型/** var arraystring, mixed $config */$config json_decode($jsonString, true);衡量成功跟踪这些指标来衡量静态分析的成功。理解 PHP 性能分析有助于将静态分析改进与应用程序性能相关联12345678910111213141516171819202122// 要跟踪的指标classStaticAnalysisMetrics{publicfunctiongetMetrics():array{return[phpstan_errors$this-countPhpStanErrors(),psalm_errors$this-countPsalmErrors(),code_coverage$this-getCodeCoverage(),type_coverage$this-getTypeCoverage(),bugs_prevented$this-getBugsPrevented(),];}privatefunctioncountPhpStanErrors(): int{// 解析 PHPStan 输出$output shell_exec(vendor/bin/phpstan analyze --error-formatjson);$data json_decode($output, true);returncount($data[files] ?? []);}}总结从黑粉到真香PHPStan 抓到的那个支付 bug 彻底改变了我写 PHP 的方式。从一开始的被迫使用到后来的真心喜欢这个过程挺有意思的。最大的变化不是抓 bug而是心态。以前上线代码心里都没底祈祷别出事。现在上线前心里有数该抓的错误都抓了踏实多了。写代码的思路也变了以前是写完了碰运气现在是边写边考虑类型安全。PHPStan 不光帮我找 bug还教会我怎么更严谨地思考代码逻辑。给做 Laravel 的兄弟们几个建议别急着装逼第一天就想跳级别 9醒醒吧。老老实实从 0 → 3 → 5 → 8 这么来一步一个脚印。别怕报错看到 847 个错误别慌这不是说你菜而是给你学习的机会。每修一个错误你对类型安全的理解就深一分。让团队看到好处光说静态分析有用没人信得拿实际抓到的 bug 说话。一个具体的例子胜过千言万语。强制执行把静态分析加到 CI/CD 里让它变成必须的步骤。代码过不了静态分析就别想合并这样大家就不会偷懒了。

更多文章