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

