PHP 枚举替代常量强化业务状态类型安全

发布于
1

PHP 8.1 引入原生枚举(Enum),以独立的类型语义取代传统的类常量定义方式。枚举在编译层面约束取值范围,配合 match 表达式的穷尽性检查、IDE 自动补全与静态分析工具,可将业务状态相关的缺陷从运行期前移到开发期。订单状态、支付状态、审核状态等有限集合场景,枚举是目前 PHP 生态中类型安全等级最高的实现方案。

一、传统类常量定义业务状态的局限

PHP 8.1 之前,业务状态普遍采用类常量加字符串(或整型)标量的模式:

final class OrderStatus
{
    public const PENDING   = 'pending';
    public const PAID      = 'paid';
    public const SHIPPED   = 'shipped';
    public const COMPLETED = 'completed';
}

该模式在工程实践中存在三类结构性缺陷。

1、类型系统无法约束取值范围

常量的本质是字符串标量,方法签名只能声明 string 类型。任意字符串均可传入,非法值只能在运行期通过手写校验拦截:

function updateStatus(string $status): void
{
    // 'paiiid' 之类的拼写错误可通过类型检查,缺陷被推迟到运行期
}

2、校验逻辑分散且容易遗漏

每个消费状态的入口都需要重复编写 in_array($status, [...], true)switch 校验,任何一处遗漏都会形成脏数据写入路径。

3、静态分析与 IDE 支持薄弱

常量值与常量组之间不存在类型层面的关联,PHPStan、Psalm 等工具无法自动识别”某参数只能取某组常量”,穷举遗漏、拼写错误均无法在进入生产环境前暴露。

二、PHP 8.1 枚举的核心机制

1、纯枚举与回退枚举

纯枚举(Pure Enum)仅包含 case 名称,不绑定标量值;回退枚举(Backed Enum)为每个 case 绑定 intstring 标量值,适用于需要与数据库、API 互转的场景:

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
    case Refunded  = 'refunded';
}

2、内置方法与标量互转

cases() 返回全部 case 数组;from() 由标量值解析枚举,解析失败抛出 ValueErrortryFrom() 解析失败返回 null,适合外部输入的防御性处理:

OrderStatus::from('paid');        // OrderStatus::Paid
OrderStatus::tryFrom('unknown');  // null

3、单例语义与全等比较

每个枚举 case 在进程内是单例对象,比较使用 === 全等运算符,不存在类型隐式转换问题:

$status === OrderStatus::Paid; // 恒为布尔结果,语义明确

三、枚举强化类型安全的实战路径

1、将状态展示与行为内聚到枚举

枚举允许定义方法、常量并实现接口,展示文案、合法迁移目标等业务规则可与状态定义收拢到同一处:

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
    case Refunded  = 'refunded';

    public function label(): string
    {
        return match ($this) {
            self::Pending   => '待支付',
            self::Paid      => '已支付',
            self::Shipped   => '已发货',
            self::Completed => '已完成',
            self::Cancelled => '已取消',
            self::Refunded  => '已退款',
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, match ($this) {
            self::Pending   => [self::Paid, self::Cancelled],
            self::Paid      => [self::Shipped, self::Refunded],
            self::Shipped   => [self::Completed],
            default         => [],
        }, true);
    }
}

调用侧的类型签名从 string 收紧为 OrderStatus,非法状态在参数传递阶段即被引擎拒绝:

function transition(OrderStatus $from, OrderStatus $to): void
{
    if (!$from->canTransitionTo($to)) {
        throw new DomainException('非法状态迁移');
    }
}

2、状态迁移规则的图形化表达

上方已展示的订单状态迁移图即为该枚举定义所约束的状态机:节点对应枚举 case,箭头对应 canTransitionTo() 方法放行的合法迁移,未出现在图中的迁移路径在代码层面被统一拒绝。状态迁移规则内聚在枚举内部之后,新增状态只需修改枚举定义与 match 分支,所有调用点的校验逻辑自动同步,消除了常量模式下多处维护迁移白名单的一致性问题。

3、match 表达式的穷尽性检查

match 是严格比较的表达式,未命中任何分支且未提供 default 时抛出 UnhandledMatchError。当枚举新增 case 而 match 分支未同步更新时,运行期会立即抛出异常,PHPStan、Psalm 在 CI 阶段亦可静态报告缺失分支,形成开发与集成双重防线。

4、数据库与外部协议的互转

回退枚举的标量值即持久化值,写入数据库前读取 ->value,读取后经 tryFrom() 还原,非法库存值在入口处显式暴露:

$status = OrderStatus::tryFrom($row['status'])
    ?? throw new UnexpectedValueException('订单状态数据异常');

四、枚举与常量的工程差异对照

维度

类常量

原生枚举

参数类型约束

仅标量类型,无法限制取值

枚举类型即合法值集合

非法值暴露时机

运行期手写校验

传参阶段 TypeError

全等比较语义

需自行保证严格比较

单例对象,=== 天然成立

行为内聚

需额外工具类

枚举内直接定义方法

match 穷尽检查

运行时异常 + 静态分析

适用版本

全版本

PHP ≥ 8.1

枚举本身的性能开销与常量可忽略差异,case 解析由引擎在编译期完成,业务项目无需因性能顾虑回避枚举。

五、使用边界与注意事项

枚举 case 不允许定义属性,状态以外的可变数据应放在使用枚举的实体类中;枚举不支持继承,可实现接口以抽象跨模块的状态契约;回退枚举的标量值在全部 case 间必须唯一,重复值将触发编译错误。

常见问题(FAQ)

PHP 枚举从哪个版本开始可用?
原生枚举自 PHP 8.1(2021 年 11 月发布)起可用,低版本项目可通过 myclabs/php-enum 等用户态库模拟,但无法获得引擎级类型约束与单例语义。
纯枚举与回退枚举应当如何选择?
仅用于进程内逻辑分支时选纯枚举;需要写入数据库、经 JSON 与外部系统交换、或对接既有标量协议时选回退枚举。回退枚举绑定 string 值的可读性优于 int 值,便于日志排查。
枚举能否定义属性或继承其他类?
不能。枚举禁止声明属性,也不允许继承与被继承(枚举自身隐式实现 UnitEnum 接口,回退枚举额外实现 BackedEnum 接口)。需要携带可变数据的场景应将枚举作为实体类的字段类型使用。
回退枚举如何进行 JSON 序列化?
回退枚举经 json_encode() 序列化时输出其标量值;纯枚举不支持直接序列化,需实现 JsonSerializable 接口自定义输出。反序列化方向统一使用 from() 或 tryFrom() 显式还原。
存量常量代码迁移到枚举的成本高吗?
迁移可分步进行:先定义与常量值一致的回退枚举,再将方法签名从标量类型逐个收紧为枚举类型,最后删除旧常量。标量值保持一致期间,数据库与外部接口无需任何变更,迁移风险可控。
0 讨论
热门最新
总结
暂无总结
0 / 600
嗨,下午好!
所有的成功,都源自一个勇敢的开始
¥10.00
10元抵扣券
已过期