PHP 类型系统演进:从弱类型到强类型静态检查(PHPStan/Psalm)的工程化演进路径

各位下午好,或者说,各位晚上好。欢迎来到这场关于“PHP 类型系统:从裸奔到穿秋裤”的硬核讲座。

我知道,在座的各位中,有些人是 PHP 的“原教旨主义者”,有些人是“React/Vue 转 PHP 的前端老兵”,还有些人,你们可能听说过 PHP,觉得它就像是那个只会写 var_dump 然后删除代码的“渣男/女”同事。但请坐好,今天的主题不是 PHP 是否死了,而是 PHP 是如何从“精神病院”,进化到“硅谷顶级大厂后端”。

我们今天要聊的是:PHP 类型系统的演进,以及我们如何在代码里建立起这该死的秩序。


第一章:旧时代的幽灵——PHP 4/5 时代的“混沌魔法”

如果把 PHP 的历史比作一个青春期叛逆的少年,那 PHP 4 到 PHP 5 之前的那段时间,简直就是这名少年的噩梦。那时候的 PHP,核心哲学只有一句话:“只要能跑,别管它脏不脏。”

在那个年代,你可以在写代码的中间突然定义一个常量,也可以把一个变量一会儿当字符串,一会儿当整数,甚至有时候当数组。这就像是你早上穿西装去开会,下午去泥坑里游泳,晚上去蹦迪,衣服不换,鞋子不脱。

举个经典的“史前遗迹”代码示例:

<?php
// 这里的变量 $id 既可以是数字,也可以是字符串
function processUser($id) {
    // 试图把字符串当做数字
    $count = count($id); 

    // 这是一个非常经典的 PHP 4 惯用写法
    if (isset($id['name'])) {
        $name = $id['name'];
    } else {
        $name = "Unknown User";
    }

    // 动态属性赋值,神马东西都能塞进去
    $user->data = "随便塞个数据";

    return $name;
}

// 运行时错误?不,那是惊喜
processUser("123"); // 这里可能没问题,但逻辑全乱套了
processUser(['name' => 'Alice']); // 这里也没问题

看到没?在旧版 PHP 中,$id 是个通用的容器。你把字符串传进去,函数可能吞下去;你把数组传进去,它又以为是数组。代码运行起来就像是在玩俄罗斯轮盘赌,而且你不知道什么时候枪里会有子弹。

那时候我们怎么调试?我们靠日志。我们靠 var_dump,我们靠 print_r,我们靠在凌晨三点盯着屏幕,试图通过输出结果反推代码是怎么跑偏的。如果这时候有 PHPStan,它早就指着你的鼻子骂人了:“兄弟,你这代码写得像是在发疯。”

那时候的“强类型”是什么?是你在写代码时,脑子里想的是类型,但敲键盘的时候手不诚实。这种“隐式类型转换”(Implicit Type Juggling)是 PHP 的双刃剑,也是它早期名声臭烘烘的罪魁祸首。它能省去很多类型转换的代码,但也省去了很多检查错误的步骤。


第二章:觉醒的边缘——PHP 7 的“标量类型声明”

终于,PHP 意识到自己穿秋裤太丑了,它开始想要正经点。PHP 7 的出现,就像是这位少年第一次穿上了西装,扣子扣得紧紧的。

declare(strict_types=1);,这句代码就像是给代码立下的宪法。

<?php
declare(strict_types=1);

// 现在,如果不匹配,直接抛出 TypeError
function add(int $a, int $b): int {
    return $a + $b;
}

// 下面这行代码在 PHP 7.0 之前能跑通,7.0 之后直接炸裂
// add(1, 2.5); 

这是历史性的一步。int, float, string, bool 这四种基本类型终于有了“身份证”。这标志着 PHP 从“动态语言”正式迈入了“静态类型”的门槛。虽然它当时还不支持对象类型(那是 PHP 7.1 的事),但这已经足以让那些习惯了 Python 脚本或者 JS 开发的开发者,稍微安下心来。

但是,仅仅靠 PHP 自身的类型系统,就像是你只戴了安全帽去工地干活,虽然有了点保护,但面对深不见底的风险,你还是心慌。


第三章:照妖镜的诞生——为什么我们需要 PHPStan/Psalm?

这时候,PHPStan 和 Psalm 闪亮登场了。它们不是语言本身,但它们是比语言本身更了解你的“严厉导师”。

在 PHP 7 之前,你要想知道一个变量是不是数组,得写 is_array($var)。这种运行时的检查,就像是你出门前在门口照镜子确认自己穿没穿裤子。但 PHPStan 做的是什么呢?它在你写代码的时候就告诉你,而且不需要你运行代码,不需要你发请求,不需要你造数据。

举个 PHPStan 指出错误的例子:

<?php
function findUserById($users, $id) {
    // 这里,PHPStan 会问:$users 到底是个啥?
    // 是个数组?是个对象?还是个 Generator?

    // 假设 $users 是一个数组
    foreach ($users as $user) {
        // 这里 $user 可能是 string,也可能没有 name 属性
        // PHP 7.0 时代,你可能会在下一行直接调用 $user->name,然后报错
        return $user;
    }

    return null;
}

// 调用者
$users = ['1', '2', '3']; // 这是字符串数组,不是 User 对象数组
$user = findUserById($users, 1);
echo $user->name; // 运行时 Fatal Error: Uncaught TypeError: 
                  // Call to member function name on string

引入 PHPStan 后,你写完这段代码,编译报错:

Parameter #1 $users of function findUserById expects array<int, string>, 
array<string, string> given.

看,这就是静态类型检查的威力。它消灭了“运行时才发现的尴尬”。


第四章:全面武装——PHP 8.0 之后的“超级赛亚人”形态

到了 PHP 8.0,PHP 的类型系统进化到了一个新高度。它不再是那个只会检查基本类型的“小透明”了,它开始像 TypeScript 和 Java 一样威严。

1. 联合类型(Union Types)
以前,如果你想要一个参数接受 intstring,你只能靠文档说明,或者靠注释。现在,PHPStan 会在 PHP 8.0 中自动支持(实际上 PHP 8.0+ 原生支持)。这简直是强迫症的福音。

<?php
function getStatus(int|string|null $status): string {
    if ($status === null) {
        return 'unknown';
    }
    return (string)$status;
}

2. 返回类型声明
以前我们喜欢 return null;,甚至 return;(隐式返回 null)。现在,显式的返回类型声明让我们知道函数到底该不该吐出值。

<?php
function fetchUser(int $id): ?User { // 问号表示可能返回 null
    // ...
    return new User();
    // return null; // 这里如果返回 null,就会报错,因为声明了要返回 User
}

3. 构造器属性提升
这虽然不算纯粹的类型系统,但配合类型系统,让代码更干净。你再也不用写一大堆 private $name; 然后再去构造函数里赋值了。

<?php
class User {
    public function __construct(
        public readonly string $name, // readonly 是 PHP 8.1 引入的,不可变属性
        public int $age
    ) {}
}

在这个阶段,PHP 的代码看起来越来越像其他强类型语言了。这时候,静态分析工具(PHPStan)的作用就不仅仅是报错,而是开始提供“代码补全”和“智能跳转”。它不再是发现错误的警察,而是给你提供信息的向导。


第五章:工程化演进路径——如何将你的项目从“泥坑”拉回“正规军”

现在,理论讲完了。我知道你们中最关心的问题不是 PHP 有多强,而是:“我手头维护着一个 5 年历史的 Laravel 项目,代码里全是 @var 注释,我不可能重写,怎么办?”

别慌。这就是工程化演进路径

我们分三个阶段来进行。别指望一天之内从 PHP 5 变成 PHP 8 + Psalm 10,那是自杀。

阶段一:M1(先运行,后修复)—— 抱着死狗不撒手

如果你维护的是个 500 万行代码的遗留项目,你绝对不能一上来就禁止运行时错误。那样你会把所有功能都干掉。

在这个阶段,你的目标不是追求 100% 的静态分析覆盖,而是捕获那 80% 的显眼错误

策略:

  1. 安装 PHPStan: 在你的 composer.json 里加个依赖。
  2. 配置 Level: 从 Level 0 开始。Level 0 只做最基本的基本检查,几乎不会有报错。这能让你建立信心。
  3. 引入 @phpstan-ignore-next-line 这是慈悲为怀。如果某个地方确实因为历史原因必须这么做,写个忽略注释。不要为了静态检查去修改业务逻辑。
// config/phpstan.neon.dist
parameters:
    level: 0
    paths:
        - src

阶段二:M2(混合模式)—— 痛苦的分娩期

这是最痛苦的阶段。你要开始给老代码“打补丁”。你会发现,一旦你把 PHPStan 的 Level 提升到 5 或者 8,屏幕上全是红色的波浪线。这就像是你突然意识到自己肚子里怀的是个怪物。

策略:

  1. 逐个模块修复: 别管整个项目,先修 src/User 模块。修好了,跑通,提交。
  2. 函数级类型化: 从那些你经常调用的公共函数开始。给它们加上 int, string 或者 array 类型。
  3. 接受 mixed 类型: 对于那些不知道是什么类型的老旧变量,暂时用 mixed 类型(PHPStan Level 7+ 支持)或者 @var 注释来安抚它。
// 原代码
function getConfig($key) {
    // ...
}

// 改进后
/**
 * @param string $key
 * @return array<string, mixed>
 */
function getConfig(string $key): array {
    // ...
}

在这个阶段,你会痛并快乐着。痛是因为你要改很多 bug,快乐是因为你突然发现某个函数里有个隐藏了 3 年的 Bug(比如空指针访问)。

阶段三:M3(全静态)—— 达成圣杯

当你的代码库大部分已经经过了洗礼,你就可以把 Level 拉到 9、10 甚至 11(最高级别)。这时候,你的代码就像是由瑞士钟表匠打磨出来的。

策略:

  1. 属性类型化: 给所有类属性加上类型。不要相信 @var 注释,那是骗自己的。
  2. 消除 any 尽量不要用 any 类型,强迫自己思考变量到底是什么。
  3. 代码重构: PHPStan 会告诉你哪里需要重构。比如 if (is_string($x)) 这种逻辑判断,通常意味着你需要更好的类型设计(比如使用 Enum)。

第六章:实战演示——从“随性”到“严谨”

让我们看一个具体的例子,展示一个函数是如何在 PHP 5 风格和 PHP 8 + PHPStan 风格之间进化的。

场景:处理订单支付状态。

死亡代码(PHP 5 风格)

<?php
// 没有任何类型声明,依赖注释或者运气
function updatePaymentStatus($orderId, $status) {
    // 1. 数据库查询
    $order = $db->query("SELECT * FROM orders WHERE id = " . $orderId);

    // 2. 转换
    // $status 可能是 'paid', 'failed', 1, 0,或者是 null
    if (!$status) {
        $status = 'pending';
    }

    // 3. 更新数据库
    // 这种写法在 PHP 5 中非常常见,但不安全
    $order['status'] = $status;
    $db->update($order);

    // 4. 发送邮件
    // 如果 $order['email'] 是 null 怎么办?可能报错
    mail($order['email'], 'Payment Updated', 'Your order is ' . $status);
}

进化代码(PHP 8 + PHPStan)

<?php
namespace AppService;

use AppEntityOrder;

class PaymentService
{
    public function __construct(
        private PDO $db
    ) {}

    /**
     * 强制类型约束,明确返回值
     * @param int $orderId
     * @param string $status
     * @return void
     */
    public function updatePaymentStatus(int $orderId, string $status): void
    {
        // 1. 预处理语句,防止 SQL 注入(这是必须的)
        $stmt = $this->db->prepare("SELECT email, status FROM orders WHERE id = :id");
        $stmt->execute(['id' => $orderId]);
        $order = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$order) {
            // 明确处理异常
            throw new OrderNotFoundException("Order not found: {$orderId}");
        }

        // 2. 逻辑控制,使用空合并运算符,代码更优雅
        $finalStatus = match($status) {
            'paid', 'pending' => $status,
            default => throw new InvalidStatusException("Invalid status: {$status}"),
        };

        // 3. 属性访问,PHPStan 确保了 $order['email'] 确实存在
        // 并且确认它是个字符串
        if (!is_string($order['email'])) {
            // PHPStan 在这里会报错:The type of $order['email'] can never be string
            // 你必须在这里做防御性编程
        }

        $this->db->updateStatus($orderId, $finalStatus);

        // 4. 发送邮件,类型安全
        // 如果这里没有 $order['email'],PHPStan 会直接炸掉,不用等到运行时
        $this->sendEmail($order['email'], "Payment Updated", "Status: {$finalStatus}");
    }

    private function sendEmail(string $to, string $subject, string $body): void
    {
        // ...
    }
}

你看,区别在哪里?

  1. 安全性: SQL 注入?不存在的。空指针?不存在的。
  2. 可读性: 函数签名就像是说明书,谁来了一眼就知道要传什么。
  3. 可维护性: 你加了一个新需求,比如要记录日志。在 PHP 5 时代,你得在函数里到处 var_dump 或者写日志。现在,你只需要在函数开头加一行 Logger::info(...),PHPStan 会帮你检查日志格式对不对,参数对不对。

第七章:给“自由派”开发者的最后通牒

我知道,你们中有些人喜欢 PHP 的灵活性。你们觉得,“我写代码就像写诗,类型系统就是标点符号,限制了我的灵感”。

兄弟,别逗了。你的代码不是诗,是屎山。屎山是经不起风吹雨打的。

一旦你的项目扩展到 3 个人以上,或者代码行数超过 10,000 行,所谓的“灵感”就会变成“灾难”。你上周写的那个 extract($data) 代码块,今天会不会给自己挖个坑?

PHPStan/Psalm 给你的不仅仅是错误提示,它还帮你把代码的“灵魂”锁了起来。

当你看到红色的波浪线时,你实际上是在和编译器吵架。你最后不得不承认:“好吧,你是对的,我得把这个 int 改成 float,或者把这个 null 处理一下。”

这就是重构。不经过静态检查的重构,就像是在黑暗中拆除炸弹,你每动一下手指,都在计算引信的长度。而有了静态分析,你是在明亮的灯光下,看着地图拆除炸弹。


结语

PHP 的类型系统演进,是一部从混乱到有序的奋斗史。从 PHP 4 的裸奔,到 PHP 7 的裹紧秋裤,再到 PHP 8 的全副武装,以及 PHPStan 带来的工程化自律。

这不仅仅是语法的变化,这是一种思维方式的重塑。它要求你在按下回车键之前,先在脑子里过一遍逻辑。

如果你现在的项目还在用 @var 注释来骗自己,如果你还在忍受 Undefined variable 的运行时报错,那么,请立刻去安装 PHPStan。哪怕是从 Level 0 开始,哪怕是从一个文件开始。

不要让你的代码在没有盔甲的情况下面对互联网的枪林弹雨。去吧,赋予你的变量灵魂,用类型定义它们的行为。

谢谢大家。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注