上周把一个 Laravel 9 项目升到 10.4,CI 炸了十七处 deprecated。不是运行时报的,是 PHPStan 在 level 7 卡住的。
本地 php -l 根本没动静,日志里也查不到。Facade 静态调用这东西,写的时候是真的爽,但等到要查它在哪被调过,跟扫雷似的——你永远不知道哪一行会爆。
后来花了两个晚上写了条自定义规则,把这事从「靠人肉 review 碰运气」变成了「CI 自动拦截」。过程中踩了不少坑,写出来就当留个记录,说不定对你有用。
Facade 这东西,怎么就成了技术债的窝
Facade 这东西,是服务容器给你开的一扇小后门——用起来确实方便,敲几下键盘就能调服务。但后门走多了,真正的依赖链路反而被绕过去了,等你要重构或者写测试的时候,才发现代码跟容器早焊死在了一起。
你看一眼 Cache::get(),根本搞不清它绑的是 Illuminate\Cache\Repository 还是某个自定义驱动。单元测试的时候 mock 成本直线上升,重构的时候更不敢乱动——谁敢保证 Mail::send() 没被某个 __callStatic 劫持过?反正我不敢。
Laravel 从 9.x 开始批量给 Facade 方法打 @deprecated 标记,比如 Schema::drop()、Request::segment()。到了 10.x,App::make() 通过 Facade 访问容器的行为也被明确标为弃用。但这些又不是语法错误,php -l 压根不管,phpunit 也不会因此中断。等它真出问题,往往是线上已经报 500 了。
手动 grep?$facade = 'Auth'; $facade::user() 这种反射调用根本抓不到。靠日志?PHP 8.3+ 默认把 deprecation 警告关了,CI 环境里连个提醒都看不到。
静态分析不是银弹,但 PHPStan 能在 CI 的第二关就把这些「能跑但不该跑」的调用筛出来——比等人测出缓存失效再修,至少早个两三天。
搭 PHPStan 检测环境:从配置到自定义规则
别一上来就写规则。架子没搭稳,后面排查半天发现是配置漏了——我干过这种事,不止一次。
Laravel 项目装 PHPStan 其实就两步,但版本锁死是个大坑。有人直接 ,然后跑起来报一堆「类不存在」——因为没装 larastan 和 Laravel 的桩文件。推荐的做法是:
composer require --dev larastan/larastan:^2.9
这个包会自动拉好 和 Laravel 的 stubs。注意版本:Laravel 9 用 larastan 2.x,Laravel 10 和 11 用 3.x。装完直接生成配置文件:
它会问你要 level。我建议新项目设 6,老项目从 2 开始——别一上来就 level 9,那是自找麻烦。生成的 大概长这样:
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 6
paths:
- app
scanFiles:
- vendor/larastan/larastan/stubs/LaravelServiceProvider.stub
这里有个隐藏坑:scanFiles 里必须把 Laravel 的桩文件列进来,否则 Facade 的 @deprecated 注解根本读不到。我 debug 了两个小时才发现 getNodeType() 一直返回 null,就是因为 \Illuminate\Support\Facades\Cache 的 docblock 压根没被加载。
接下来写自定义规则。继承 PHPStan\Rules\Rule 接口,实现两个方法:
<?php
declare(strict_types=1);
namespace App\PHPStan;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Node\StaticMethodCallNode;
/**
* @implements Rule<StaticMethodCallNode>
*/
class DeprecatedFacadeCallRule implements Rule
{
public function getNodeType(): string
{
return StaticMethodCallNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
$class = $node->getClass();
if (!is_string($class) || !str_starts_with($class, 'Illuminate\Support\Facades\\')) {
return [];
}
$methodName = $node->getName();
if (!is_string($methodName)) {
return [];
}
try {
$refClass = new \ReflectionClass($class);
if (!$refClass->hasMethod($methodName)) {
return [];
}
$refMethod = $refClass->getMethod($methodName);
$docComment = $refMethod->getDocComment();
if ($docComment && str_contains($docComment, '@deprecated')) {
return [
\PHPStan\Rules\RuleErrorBuilder::message(
sprintf(
'Facade 静态调用 %s::%s() 已被 @deprecated 标记,建议注入底层服务替代',
$class,
$methodName
)
)->line($node->getLine())->build(),
];
}
} catch (\ReflectionException $e) {
}
return [];
}
}
注意 getNodeType() 返回的是 ,不是 Expr\StaticCall。前者是 PHPStan 封装后的节点,能直接拿到 ->getClass() 返回解析后的类名。用 Expr\StaticCall 的话,你得手动处理别名和 use 导入——我试过一次,不想再试第二次。
注册规则到 :
services:
-
class: App\PHPStan\DeprecatedFacadeCallRule
tags:
- phpstan.rules.rule
跑一下 ,如果 Cache::get() 或 Schema::drop() 被你调用了,应该会看到类似这样的输出:
------ ---------------------------------------------
Line app/Http/Controllers/UserController.php
------ ---------------------------------------------
42 Facade 静态调用 Illuminate\Support\Facades\Schema::drop() 已被 @deprecated 标记,建议注入底层服务替代
------ ---------------------------------------------
第一次看到这个输出时,我愣了几秒——以前人工 review 完全漏掉的地方,现在 CI 第三关就抓出来了。但这只是最基础的版本,接下来要让规则知道「到底该换成什么」,才算真正能用。
让规则告诉你「换什么」
刚才那个规则能报错,但有个致命缺陷:它只告诉你「这玩意废弃了」,却不告诉你「该换成什么」。
有次在一个老项目里看到 Mail::send() 被调了二十多次,规则全命中了,但团队里新来的同事盯着报错信息,完全不知道去哪找替换方案。这跟没报一样。
所以真正要做的,是把 Laravel 容器里的绑定关系反向映射出来。Cache::get() 背后是 Illuminate\Cache\CacheManager,而 又实现了 Illuminate\Contracts\Cache\Repository。你注入 Repository 接口,比直接注入 更合理,也符合契约设计。
映射关系怎么来?我踩过两个坑。
第一个是手写一个配置文件,把每个 Facade 类和推荐接口写死。结果 Laravel 发个小版本,Bus Facade 的底层换了,维护这个映射表变成了第二份诅咒。
第二个是用反射读 Facade::getFacadeAccessor() 拿到的 $app->make() 别名,再查容器里有没有对应的契约接口。这办法更智能,但 getFacadeAccessor() 返回的是字符串(比如 'cache'),你得自己定义一套别名到接口的查找表——至少不用每次升级都改。
我的规则里最终用了第二种。在 processNode() 中拿到被调用的 Facade 类名后,反射调用 getFacadeAccessor(),得到容器别名,然后查一个 $contractMap 数组:
private function resolveFacadeContract(string $facadeClass): ?string
{
try {
$accessor = $facadeClass::getFacadeAccessor();
} catch (\Throwable $e) {
return null;
}
$map = [
'cache' => 'Illuminate\Contracts\Cache\Repository',
'config' => 'Illuminate\Contracts\Config\Repository',
'db' => 'Illuminate\Contracts\Database\DatabaseManager',
'mail' => 'Illuminate\Contracts\Mail\Mailer',
'queue' => 'Illuminate\Contracts\Queue\Queue',
'schema' => 'Illuminate\Contracts\Database\Schema\Builder',
'session' => 'Illuminate\Contracts\Session\Session',
'storage' => 'Illuminate\Contracts\Filesystem\Filesystem',
];
return $map[$accessor] ?? null;
}
然后在错误信息里拼接成这样的提示:
"Facade 静态调用 {$facadeClass}::{$method}() 已被 @deprecated 标记,建议注入 {$contract} 替代"
现在 CI 输出变成了这样:
------
Line app/Http/Controllers/UserController.php
------
42 Facade 静态调用 Illuminate\Support\Facades\Cache::get() 已被 @deprecated 标记,
建议注入 Illuminate\Contracts\Cache\Repository 替代
------
你直接在构造函数里加上 Repository $cache,把 Cache::get() 换成 $this->cache->get()——不用翻文档,不用猜接口名。
至于 CI 集成,其实就是一行配置的事。在 .github/workflows/ci.yml 里加一个 step:
- name: PHPStan with deprecated facade check
run: vendor/bin/phpstan analyse --configuration=phpstan.neon --level=max
我更倾向把分析结果输出到 GitHub Actions 的注解里,这样 PR 页面上每个违规行旁边直接标红,reviewer 不用打开日志找文件。用 phpstan --error-format=github 就能做到。
一个晚上写完这个规则,第二天 CI 就拦住了三个 Session::put() 的调用——其中一个线上环境已经因为 Session Facade 在队列作业里状态不一致而修过两次了。说实话,这比什么 code review 流程改进都立竿见影。
别把映射表写死得太大,几十个核心 Facade 就够了。剩下的等 CI 报出来再补,因为实际项目里常用的也就那十几个。
从静态调用到依赖注入:动手改起来
规则写好了,CI 也红了,接下来就是动手改。这一步最枯燥,但也是最能看出收益的地方。
拿到 PHPStan 的报错列表后,我建议按文件分组,从 和 app/Jobs 开始。这两类文件里的 Facade 调用通常最频繁,而且重构风险可控——控制器和队列 Job 的构造函数注入是 Laravel 最成熟的模式。
有一个常见坑:有人直接把 Cache::get('key') 改成 $this->cache->get('key'),但忘了检查当前类是不是已经继承了 Illuminate\Support\Traits\ForwardsCalls 或者混入了别的 trait。如果父类构造函数已经注入了 Cache 实例,你再注一次就会冲突。所以改之前扫一眼构造函数的参数列表,必要时用 IDE 的「查找使用处」确认一下。
还有一个容易被忽略的:全局辅助函数。比如 cache()、event()、dispatch(),这些函数底层调用的也是对应的 Facade。PHPStan 默认不会报辅助函数的废弃(除非你额外写了规则),但重构时最好一并处理。
拿 cache() 举例:
// 重构前
$value = cache('user_'.$id, function () {
return User::find($id);
});
// 重构后
use Illuminate\Contracts\Cache\Repository;
public function __construct(
private Repository $cache
) {}
public function getUser(int $id): User
{
return $this->cache->remember('user_'.$id, 3600, function () use ($id) {
return User::findOrFail($id);
});
}
注意参数顺序和默认行为可能不一样。cache() 辅助函数的第二个参数是闭包,而 Repository::remember() 的第二个参数是过期时间(秒)。这种细节差距最容易在替换后静默地引入 bug。
很多人改完业务代码就跑了,测试全挂。因为之前用 Facade::shouldReceive() 的 mock 方式,换成依赖注入后已经不生效了。你需要把测试里对 Cache::shouldReceive('get') 的调用,改成 mock 构造函数里注入的那个 Repository 实例。
这一步比改业务代码还繁琐。但反过来想——测试写得更干净了,不再依赖全局状态,每个测试的 mock 对象和被测类之间的耦合也更清晰。
如果你项目里有一千多处 Facade 调用,别妄想一个周末改完。PHPStan 提供了 --generate-baseline 参数,能把当前所有报错导出到一个 文件里。后续新增的违规才会报红,已有的不计入。
具体做法:
vendor/bin/phpstan analyse --configuration=phpstan.neon --level=max \
--generate-baseline=phpstan-baseline.neon
然后在 里 include 这个 baseline 文件。这样你可以在迭代中每次改一批,改完删掉 baseline 里对应的条目,CI 不会因为存量报错而变红。这种方式比直接在代码上加 @phpstan-ignore-next-line 干净得多,至少你不会忘记回头清理。
每次改完十个文件,就重新生成一次 baseline,保证新写的代码不会再冒出 Facade 调用。三个月折腾下来,项目从两百多处报错一路掉到个位数——那段时间该迭代迭代、该发版发版,一次没耽误。
到最后你会发现,真正棘手的不是改代码本身,而是让团队接受「静态调用不再是默认选择」这件事。但看到 PR 上那些被 CI 拦下来的 Session::put(),我觉得值。





评论