生存指南:如何用 PHPStan 和 Psalm 给老古董“换肾”—— PHP 8.4 迁移实战
大家好,欢迎来到今天的讲座。我知道你们现在的状态,我也理解你们现在的恐惧。
你们手头可能都有一个或者几个“老古董”——那些从 PHP 5.3、5.4 甚至 PHP 4 时代流传下来的代码库。它们就像你家那个生了锈的挂钟,虽然还能走,但你知道,只要稍微碰一下,它就会掉出两三个齿轮。而在 2026 年,我们的目标是 PHP 8.4。8.4!这不仅仅是 8.0 的升级,这是一次充满活力、甚至有点疯狂的进化。
很多人问:“为什么要折腾?” “现在跑得好好的,干嘛改?” “改了会不会出事?”
好,今天我就要告诉你们:不改,等它彻底停摆;改,是为了在它停摆之前,让它变成一辆法拉利。
我们将通过“静默之声”——也就是静态分析工具——作为我们的手术刀,逐步剥离 Legacy 代码的腐肉,植入 PHP 8.4 的新鲜血液。
第一部分:与“幽灵代码”共存
首先,我们要承认现实。你的 Legacy 系统里,可能充斥着以下这种令人窒息的代码:
<?php
// 这是谁写的?上帝吗?
function getUserData($id) {
$res = mysql_query("SELECT * FROM users WHERE id = " . intval($id));
$row = mysql_fetch_assoc($res);
if (!$row) return null;
$obj = new stdClass();
$obj->name = $row['name'];
$obj->age = $row['age'];
return $obj;
}
这不仅是丑陋,这是危险。没有类型提示,没有命名空间,没有严格模式。如果你现在要在 PHP 8.4 中运行这段代码,PHP 会看着你,假装什么都没发生,直到你试图给 $obj->name 加个空格,然后它告诉你“Undefined property”。
这时候,测试用例会告诉你:“Pass!”。因为测试代码可能就是这样写的。这就是测试的谎言。 测试覆盖了逻辑,但没覆盖类型系统。测试能骗过 PHP,但骗不过你的 IDE 和静态分析工具。
所以,我们引入第一件武器:静态分析工具。想象一下,有一个不知疲倦、眼神犀利、手里拿着红笔的老师,时刻盯着你的屏幕。他不会运行代码,他只看代码的“骨架”。
我们有两个主流选手:Psalm 和 PHPStan。
- Psalm 是个激进派,他喜欢咬文嚼字,甚至比你自己还清楚你的代码意图。
- PHPStan 是个温和派,他更擅长在代码跑飞之前把你拽住。
为了演示,我将以 PHPStan(版本 2.x/3.x 即将支持 8.4)为主,但 Psalme 的用法大同小异。
第二部分:建立“静默”的防线
在动手大改之前,我们需要先建立一个规矩。你不能直接把 PHP 版本从 7.4 拉到 8.4,然后指望工具把你的错误都修好。那是不可能的。你得先建立防线。
1. 引入 Composer 和 Strict Types
如果你的 Legacy 系统还没用 Composer,那就别说了,先去修这个,改完再回来听讲座。有了 Composer,我们才能控制依赖。
第一步,在所有 PHP 文件的顶部加上 declare(strict_types=1);。
<?php
declare(strict_types=1);
// 现在你的代码从此有了底线
function add(int $a, int $b): int {
return $a + $b;
}
2. 配置 PHPStan
创建一个 phpstan.neon 文件。这不仅仅是配置,这是给你的工具立规矩。
parameters:
level: 5 # 从 0 开始,慢慢往上调,不要一口吃成胖子
paths:
- src
- app
ignoreErrors: [] # 嘿,这里本来应该填你要忽略的错误,但别先填!
checkGenericClassInNonGenericObjectType: true
当你运行 vendor/bin/phpstan analyse 时,你会发现屏幕上刷屏了。是的,那是你的 Legacy 代码在尖叫。别怕,这是它肺里的废气排出来。
第三部分:PHP 8.4 的特洛伊木马
好了,现在我们有了防线,可以开始引入 PHP 8.4 的新特性了。PHP 8.4 带来了三个改变游戏规则的东西:object 返回类型、属性钩子、以及更强大的静态属性类型。
1. 返回类型 object:终于不用玩猜谜游戏了
在 PHP 8.4 之前,如果你写了一个工厂函数,返回一个对象,但你不确定具体返回什么子类,你通常会写 @return object。或者更糟糕,你写 @return MyClass,但实际返回了 MyChildClass。
静态分析工具虽然聪明,但面对这种“手动注释”往往会傻眼。
PHP 8.4 引入了原生 object 返回类型。这意味着:这个函数必须返回一个对象,不管是哪个对象。
Legacy 场景:
// 旧时代的工厂
/**
* @return stdClass
*/
function createConfig() {
// 偶尔你会忘了加 return new stdClass();
return new stdClass();
}
// 静态分析工具只会干瞪眼,或者警告你注释可能不准。
8.4 时代:
// 终于,真相大白
function createConfig(): object {
// 现在工具可以确保:这里必须有一个 return 语句,而且返回值必须是一个对象实例。
// 如果你不写 return,或者你 return 了 null,PHPStan 会立刻报警!
return new stdClass();
}
实操迁移:
不要只改函数签名。去检查那些工厂函数。如果它们返回的是集合(比如数组),那是 array。如果它们返回的是标量(整数、字符串),那是 int、string。只有当返回值真的是一个对象时,才用 object。这能让你的代码在 IDE 里获得完美的智能提示。
2. 属性钩子:消灭丑陋的 Getter/Setter
这是一个重头戏。如果你的 Legacy 系统里充满了这种代码:
class User {
private string $name;
public function getName(): string {
return $this->name;
}
public function setName(string $name): void {
$this->name = $name;
}
}
你会觉得看着恶心吗?是的,这就是我们以前的做法。为了封装,我们写了两个函数,而逻辑只有一行。
PHP 8.4 引入了 #[Getter] 和 #[Setter] 属性钩子。它们把逻辑从方法里搬到了属性定义里。
8.4 写法:
class User {
public function __construct(
public string $name, // 直接公开属性,方便!
) {}
}
// 如果你需要一个带验证的 Setter:
class User {
private string $name;
public function setName(string $name): void {
if (strlen($name) < 3) {
throw new InvalidArgumentException("Name too short");
}
$this->name = $name;
}
#[Getter] // 8.4 特性:自动生成 getter
public function getName(): string {
return $this->name;
}
}
注意看,getName 方法只有一行,它是多余的。#[Getter] 告诉编译器:“嘿,别为我生成什么乱七八糟的 Getter 了,帮我自动生成,参数和返回值照搬下面这个方法就行。”
静态分析的作用:
当你使用 #[Getter] 时,PHPStan 会知道这个属性已经被代理了。如果你在代码里尝试 $user->name = "Bob";(直接访问),PHPStan 会警告你:“你正在直接修改私有属性,应该用 setName 方法。” 这简直比你自己还严格!
第四部分:进阶手术——静态属性与复杂类型
Legacy 代码最可怕的地方在于静态属性。它们通常被当作全局变量使用,充满了 null、mixed 和类型混乱。
PHP 8.4 允许我们在静态属性上使用联合类型和 nullable 标记。
Legacy 坏习惯:
class Database {
public static $connection = null;
public static function connect() {
if (self::$connection === null) {
self::$connection = new PDO('...');
}
return self::$connection;
}
}
问题:
$connection可能是任何东西(除了 PDO 之外的object也能赋值进去)。- 它可能是
null。 - 你在
connect里读取它时,必须每次检查=== null。
8.4 + 静态分析修复:
class Database {
// 现在我们可以指定具体的类型了!
public static PDO|null $connection = null;
public static function connect(): PDO {
// PHPStan 现在知道 $connection 必须是 PDO 或者 null
if (self::$connection === null) {
self::$connection = new PDO('...');
}
// 因为我们在 if 里检查了 null,PHPStan 知道这里返回的一定是 PDO
return self::$connection;
}
}
为什么这很重要?
当你用 PHPStan 开启严格模式后,任何对 Database::$connection 的访问,如果不处理 null 的情况,都会报错。这强迫你写出防御性代码。
第五部分:平滑迁移的节奏感
很多人问我:“专家,我不可能一天把几千行代码都改成 8.4 吧?”
当然不可能。你是想修好房子,不是想拆了重建。我们要采用“分层渗透”策略。
第一步:配置关卡(Level 0 -> Level 5)
别急着改代码。先配置 PHPStan。把 level 提到 5。修复所有的错误。这一步通常非常痛苦,因为你会发现无数个潜在的 Bug(比如未使用的变量、可能为 null 的变量)。但这就像体检,发现病灶总比等到癌症晚期好。
第二步:引入 Strict Types 和类型提示
不要试图一次性给所有函数加上返回类型。从新文件开始,从最核心的文件开始。对于老文件,可以先给参数加类型。等静态分析不再抱怨,再给返回值加类型。
第三步:引入 8.4 特性
当你觉得代码已经比较“干净”了,就开始引入 object 返回类型和属性钩子。
- 先重构一个简单的 Model 类。
- 然后重构一个 Service 类。
- 最后处理那些复杂的、几百行的 Controller。
第四步:Polyfill
如果你的 Legacy 系统里引用了第三方库(比如十年前的某个 CMS),而该库不支持 PHP 8.4 的 object 类型,你可能会遇到错误。
这时候,不要去改那个第三方库(通常你也没权限)。创建一个适配器或者使用 Composer 的 patcher 功能。
// 这是一个给旧库用的适配器
final class LegacyUserAdapter {
public function __construct(
private LegacyUser $user,
) {}
// 把旧库的对象包装成新风格的 object
public function toObject(): object {
return $this->user;
}
}
第六部分:心态建设与团队协作
讲到这里,技术细节讲完了。但我知道你们最担心的是什么。是团队阻力。
“我加了 declare(strict_types=1),所有人都要改。”
“我把返回类型加上了,测试挂了。”
“我把那个属性改成 #[Getter],代码审查(Code Review)被驳回了。”
这就是迁移的艺术。平滑升级不是没有变化,而是可控的变化。
- 从“运行通过”到“类型安全”:告诉团队,测试通过只是及格线。静态分析通过才是真正的安全。
- 增量式反馈:不要让所有人一次面对几千个报错。让他们每天只修 50 个最严重的错误。
- 拥抱性能提升:PHP 8.4 的 RCU(Read-Copy-Update)机制能让你的老旧业务在并发下性能翻倍。这是你们老板最喜欢听的故事。“看,只要我们改一点代码,服务器负载就降了一半。”
结语:老树发新芽
回到 2026 年的视角。十年前,我们还在争论要不要用 foreach 代替 while。现在,我们站在了 PHP 8.4 的门槛上。
利用静态分析工具,我们不再是被动地修补漏洞,而是主动地重构架构。我们就像拿着手术刀的外科医生,在 psalm.xml 的指挥下,精准地剔除 Legacy 代码的冗余,植入 8.4 的新特性。
不要害怕报错。报错是工具在对你说话。 如果 PHPStan 报错了,那不是在刁难你,它是在说:“嘿,这里有个坑,跳过去!”
现在,拿起你的工具,打开你的终端。开始你的第一次 phpstan analyse 吧。你会发现,古老的代码虽然面目可憎,但它的骨架里依然藏着逻辑。利用 PHP 8.4,我们将释放这些逻辑,让它们焕发出第二春。
谢谢大家。现在,去干活吧!