不止替代常量,PHP8枚举实现订单状态机,让代码自己说话

2026-08-06 0 690

PHP 8.1推出了枚举类型,当时很多人的第一感觉是“这不就是个语法糖吗,用来替换常量”。但我在重构订单模块时发现,枚举真正的力量在于把状态和逻辑绑在一起,让状态迁移变成一段可以读懂的声明,而不是散落在if-else和配置里。

今天我就用订单状态机作为例子,讲清楚怎么用PHP 8的枚举做一次彻底的状态管理升级。这不仅仅是把常量换成枚举,而是让你感受到“代码自己说话”的清爽。

先说说以前写订单状态有多烦

老项目里订单状态是这个样子的:

class OrderStatus {
    const CREATED = 0;
    const PAID = 1;
    const SHIPPED = 2;
    const COMPLETED = 3;
    const CANCELLED = 4;
}

看着挺规整,但用起来你得自己记状态含义。比如要判断订单能不能取消,你可能得写:

if ($order->status === OrderStatus::CREATED 
    || $order->status === OrderStatus::PAID) {
    // 允许取消
}

这还算好的。后来状态多了,业务复杂了,就会出现这种没法看的代码:

if (in_array($order->status, [0, 1], true)) { // 0和1都可以取消,2、3不行
    // todo
}

再往后,你光靠数字根本不知道0、1是什么,IDE也不会提示。排查问题的时候,得每次去翻常量定义。这还只是状态本身,如果状态迁移还有其他逻辑,比如“已发货后只能等收货”,代码更是乱成一团。

用枚举替换常量,第一步就变清晰

PHP 8枚举来了以后,我第一件事就是把常量改成枚举:

enum OrderStatus: int {
    case CREATED = 0;
    case PAID = 1;
    case SHIPPED = 2;
    case COMPLETED = 3;
    case CANCELLED = 4;
}

先解释一下,这种是纯枚举(backed enum),后面跟了int,所以每个案例都有一个整数索引,可以直接和数据库里的int字段互相转换。

使用的时候,不再是模糊的数字,而是明确的对象:

$order->status = OrderStatus::PAID;

读的时候也是:

if ($order->status === OrderStatus::CREATED) {
    // 清晰明白
}

IDE也能自动补全,拼写错误在代码审查时就能发现。但如果你只用它来代替常量,那就太浪费了。真正的精髓是给枚举添加方法,让状态自己描述自己。

给状态添加工厂:从数据库值还原枚举

订单从数据库读出来的时候,status是int类型。我们需要把它转换成枚举对象。这个转换逻辑我直接写在枚举类里,作为静态方法:

public static function fromStatus(int $status): self
{
    return self::from($status);
}

其实`from`是PHP枚举自带的方法,但直接用`from`会抛异常。我更推荐写一个业务语义更强的方法,加一层保护:

public static function tryFromStatus(int $status): ?self
{
    return self::tryFrom($status);
}

这样不用记住`tryFrom`这个原生API,只记住业务里的“状态转换”就够用了。

核心操作:让状态枚举自己决定能不能“下一步”

订单状态机有一个核心需求:某个状态能迁移到哪些状态,不能迁移到哪些状态。以前写一堆判断,现在把这张“状态迁移表”直接塞进枚举里。

我在枚举里加一个方法:`canTransitionTo`,返回布尔值。

enum OrderStatus: int {
    case CREATED = 0;
    case PAID = 1;
    case SHIPPED = 2;
    case COMPLETED = 3;
    case CANCELLED = 4;

    private const TRANSITIONS = [
        self::CREATED => [self::PAID, self::CANCELLED],
        self::PAID => [self::SHIPPED, self::CANCELLED],
        self::SHIPPED => [self::COMPLETED],
        self::COMPLETED => [],
        self::CANCELLED => [],
    ];

    public function canTransitionTo(OrderStatus $target): bool
    {
        return in_array($target, self::TRANSITIONS[$this] ?? [], true);
    }
}

这里有个细节:`TRANSITIONS`数组的键使用`self::CREATED`这种奇奇怪怪的写法。因为我这个是纯枚举(backed enum),在枚举内部可以直接用`self::CREATED`来引用案例。它会自动转换成对应的int值?不对,实际上在枚举类内部,`self::CREATED`代表的是枚举案例,不是int。但在常量数组中,PHP有自动转换机制?可能不行。为了安全,我应该直接用 `self::CREATED->value`这样的方式。但PHP枚举的case在常量表达式中可以直接使用?测试一下想一想:

实际上,在枚举内部定义常量时,可以使用`self::CREATED`,因为PHP 8.1支持枚举案例在常量表达式中使用。但要注意,数组的键必须是int或string。这里用了`self::CREATED`,它的对象会被转换为int? 还是不允许?试过才知道,但文档中常见写法是使用`self::CREATED->value`作为键。为了保证代码不出错,我写成这样:

private const TRANSITIONS = [
    self::CREATED->value => [self::PAID, self::CANCELLED],
    self::PAID->value => [self::SHIPPED, self::CANCELLED],
    self::SHIPPED->value => [self::COMPLETED],
    self::COMPLETED->value => [],
    self::CANCELLED->value => [],
];

然后`canTransitionTo`方法要用`$this->value`作为键取数组:

public function canTransitionTo(OrderStatus $target): bool
{
    return in_array($target, self::TRANSITIONS[$this->value] ?? [], true);
}

这样就没有歧义了。

再写一个`transitionTo`方法,它把目标状态传进来,如果允许迁移就返回目标枚举,不允许就抛异常:

public function transitionTo(OrderStatus $target): OrderStatus
{
    if (!$this->canTransitionTo($target)) {
        throw new DomainException(
            "订单不能从 {$this->name} 迁移到 {$target->name}"
        );
    }
    return $target;
}

有了这个方法,业务层的代码就变得极其简洁:

$newStatus = $order->status->transitionTo(OrderStatus::PAID);
$order->status = $newStatus;

如果状态不允许迁移,异常消息直接告诉你是哪里迁移到哪里,排查问题不用再猜。

match表达式配合枚举,告别switch-case地狱

订单状态不同,执行的操作也不同。比如“已创建”的订单要开始处理库存,“已支付”的要通知物流,这些以前用switch-case写,挨个case,感觉很拖沓。用枚举加match,可以写得像表格一样整齐。

写一个在订单实体类里的方法,这里简化为一个外部函数:

function handleOrderStatusChange(OrderStatus $status, Order $order): void
{
    match($status) {
        OrderStatus::CREATED => $order->reserveStock(),
        OrderStatus::PAID => $order->notifyLogistics(),
        OrderStatus::SHIPPED => $order->sendShipmentMessage(),
        OrderStatus::COMPLETED => $order->completeLottery(),
        OrderStatus::CANCELLED => $order->restoreStock(),
    };
}

match表达式不仅不需要break,而且必须覆盖所有枚举案例,不然会抛出UnhandledMatchError。这样你在新增一个订单状态时,编译器会强制你考虑所有用到match的地方,无形中减少漏改的bug。

枚举方法里还可以用枚举参数,做更复杂的判断

现在订单待支付状态可以取消,但仅限创建后24小时内。这个规则写在哪里?我依然放在枚举方法里,让枚举自己会判断。

在`OrderStatus::PAID`的取消条件中,除了状态本身允许,还要看付款时间。为了让枚举方法能处理业务条件,我把订单对象传进去。不过更好的做法是,枚举只负责状态机规则,时间判断由调用方决定。这里我提供一个“可取消”快捷方法:

public function canCancel(Order $order): bool
{
    // 枚举定义里,只有CREATED和PAID状态能取消
    $transitionAllowed = $this->canTransitionTo(OrderStatus::CANCELLED);
    if (!$transitionAllowed) {
        return false;
    }

    // 特殊业务逻辑:已付款的订单只能在支付后2小时内取消
    if ($this === self::PAID) {
        return $order->paid_at > time() - 7200;
    }

    return true;
}

这样在取消请求里,只需要写:

if (!$order->status->canCancel($order)) {
    throw new DomainException('当前状态不可取消');
}

比之前把业务规则散落在Service里要集中多了。

状态历史记录:用枚举做存储友好的值对象

订单状态迁移需要记录历史表,历史表里存的是int值。我们可以在历史记录实体中使用枚举,并在模型访问器里自动转换。

比如我用的ThinkPHP模型(或用别的框架类似),在属性设置时强制转换为枚举:

class OrderHistory extends Model
{
    protected $casts = [
        'status' => OrderStatus::class,
    ];
}

这样从数据库读取的status字段会自动变成枚举对象,写入时也会自动转成int值。在你的业务代码里,你始终操作的是枚举,不会碰到底层数字。这让代码的自文档化能力提升了一截。

实战案例:用一个枚举让整个订单流转“无脑”

下面我构建一个简化版的订单状态流转服务,展示枚举的实际使用效果。省去框架,只留业务逻辑。

订单实体:

class Order {
    public int $id;
    public OrderStatus $status;
    public int $created_at;
    public ?int $paid_at;
}

订单服务只有两个方法:pay和ship,内部调用状态机的`transitionTo`。

class OrderService {
    public function pay(Order $order): void
    {
        $newStatus = $order->status->transitionTo(OrderStatus::PAID);
        $order->status = $newStatus;
        $order->paid_at = time();
        // 保存订单...
    }

    public function ship(Order $order): void
    {
        $newStatus = $order->status->transitionTo(OrderStatus::SHIPPED);
        $order->status = $newStatus;
        // 保存订单...
    }
}

仔细看,`ship`方法里没有任何if判断,因为状态是否允许发货已经由枚举内部决定。如果订单处于`CREATED`状态,`transitionTo(SHIPPED)`会抛DomainException,告诉你不允许从CREATED迁移到SHIPPED。这比你在Service里写一堆`if ($order->status != OrderStatus::PAID) throw …` 要直观得多。

而且你以后想增加一个“已取消”状态下不能执行任何操作,只需要在枚举的TRANSITIONS里把取消状态对应的可转列表设置成空,就自动生效,完全不用动Service。

更优雅的枚举陷阱避免

枚举状态虽然好用,但我遇到几个坑,给大家提个醒。

第一个坑:不要把业务逻辑全部堆在枚举里。我之前想把订单金额太大不能取消这种跨领域逻辑也放进去,结果枚举类越来越胖。最后明白一个道理:枚举适合放状态迁移规则,不适合放“外部环境判断”。比如”订单金额超过1000元不能取消“,这需要查询其他表,建议还是放在Service里。

第二个坑:数据库转换问题。如果用MySQL,存储枚举的int值没问题。有些团队喜欢存字符串,比如`created`、`paid`这样的,那枚举的case可以定义成string类型,效果一样。关键是要和数据库约定统一。

第三个坑:序列化。用枚举后会碰到json_encode,默认编码结果是枚举对象的所有属性,这不太好看。需要实现JsonSerializable接口来自定义输出。我通常直接在枚举上实现:

enum OrderStatus: int implements JsonSerializable {
    case CREATED = 0;
    // ...

    public function jsonSerialize(): mixed
    {
        return $this->value;
    }
}

这样前端收到的是int数字,不会莫名其妙的变成对象。

总结:枚举让状态迁移成为一种“声明”

使用PHP 8枚举实现状态机,最爽的一点是,代码的意图变得极其明显。你不需要写注释“0表示创建,1表示付款”,只需要看`OrderStatus::CREATED`就觉得理所当然。状态迁移表集中在一个类里,想要改变迁移规则,只需要改一行常量数组。

当项目里所有人都习惯用枚举之后,你会发现新来的同事看代码的速度快多了,毕竟没有哪个新人愿意去猜一个int字段代表什么意思。

如果你还在用常量字符串维护订单状态,我建议你找个时间重构一下。过程不复杂,收获却立竿见影。试着把第一个状态迁移用枚举写出来,然后你会再也回不到从前。

不止替代常量,PHP8枚举实现订单状态机,让代码自己说话
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php 不止替代常量,PHP8枚举实现订单状态机,让代码自己说话 https://www.taomawang.com/server/php/2495.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务