上周把一个 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 configuration for Laravel project

搭 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 第三关就抓出来了。但这只是最基础的版本,接下来要让规则知道「到底该换成什么」,才算真正能用。

PHPStan detecting deprecated Facade method call

让规则告诉你「换什么」

刚才那个规则能报错,但有个致命缺陷:它只告诉你「这玩意废弃了」,却不告诉你「该换成什么」。

有次在一个老项目里看到 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(),我觉得值。