做业务开发,状态流转是绕不开的话题。订单、工单、审批流、用户认证,处处都有“状态”的影子。前些年我接手过一个电商系统的订单模块,打开代码一看,订单状态全是字符串——'pending'、'paid'、'shipped'、'completed'到处散落,伴随它们的是一大堆if ($status == 'paid')、switch ($status)的判断,还夹杂着各种strtolower的防御性调用以防有人写成了'Paid'。修改一个状态逻辑,要在七八个文件里改条件判断。这种代码跑了一两年没出大问题,但每次维护都提心吊胆。
PHP 8.1引入的枚举,给我们提供了一个全新的思路。枚举不只是把一堆常量收拢到一个类里,更关键的是它支持方法定义和接口实现。用枚举来建模状态,再配合状态机的约束规则,可以把原本松散的状态逻辑收拢到一个高度内聚的模块里,外部的业务代码变得异常干净。这篇文章就讲我怎么用PHP枚举从零搭起一个强类型状态机,并且分享在实际项目里落地的几个具体案例。
一、从字符串常量到枚举——第一步的改进
在PHP 8.1之前,管理状态的最常见做法是定义一组类常量:
class OrderStatus {
const PENDING = 'pending';
const PAID = 'paid';
const SHIPPED = 'shipped';
const COMPLETED = 'completed';
const CANCELLED = 'cancelled';
}
然后到处用OrderStatus::PENDING。这样做的好处是避免了字符串拼写错误,但有一个致命缺陷:这些常量本质上仍然是字符串,方法签名里只能写string $status,无法限制调用方只能传入几个有效值。如果你在一个函数里期望接收一个订单状态,IDE也没法帮你列出所有合法选项,只能看文档。
换成枚举就能解决这个类型约束的问题:
enum OrderStatus: string {
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Completed = 'completed';
case Cancelled = 'cancelled';
}
现在你可以用OrderStatus做类型声明了。任何一个接收订单状态的方法,只要写上OrderStatus $status,调用时就只能传入枚举实例,传字符串会直接报TypeError。光是这一点,就省去了多少在函数开头做if (!in_array($status, validStatuses))的防御代码。
function updateOrderStatus(int $orderId, OrderStatus $newStatus): void {
// 不再需要校验类型,枚举已经保证了$newStatus一定是有效状态
// 执行业务逻辑...
}
二、在枚举里定义状态流转规则
类型安全只是开胃菜,枚举真正的威力在于可以把行为也封装进去。一个状态机核心要管两件事:当前状态下允许哪些操作,以及操作后迁移到什么新状态。这些规则能不能直接写在状态枚举里?当然可以。
我们来设计一个更完整的订单状态机。不是所有状态之间都能随意跳转,比如已完成的订单就不能变成待支付,已取消的订单也不能再发货。每个状态应该自己知道它可以迁移到哪些其他状态。
enum OrderState: string {
case Draft = 'draft';
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
case Refunded = 'refunded';
/**
* 返回从当前状态可以合法迁移到的下一个状态列表
* @return OrderState[]
*/
public function allowedTransitions(): array {
return match($this) {
self::Draft => [self::Pending, self::Cancelled],
self::Pending => [self::Confirmed, self::Cancelled],
self::Confirmed => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered => [self::Refunded],
self::Cancelled => [],
self::Refunded => [],
};
}
/**
* 判断是否允许迁移到指定状态
*/
public function canTransitionTo(OrderState $target): bool {
return in_array($target, $this->allowedTransitions(), true);
}
}
用match表达式把状态流转图直接写成代码,清晰得像看一张状态转移表。任何一个开发人员点开这个枚举,几秒钟就能看懂订单状态的完整生命周期。以后要加一个新状态,也只需要在这个文件里改动match的一个分支,影响范围一目了然。
接着写一个服务类来处理状态迁移的动作,它会调用枚举的canTransitionTo来做前置校验:
class OrderStateMachine {
/**
* @throws InvalidStateTransitionException
*/
public function transition(Order $order, OrderState $targetState): void {
$currentState = $order->getState();
if (!$currentState->canTransitionTo($targetState)) {
throw new InvalidStateTransitionException(
sprintf(
'不允许从 "%s" 迁移到 "%s"',
$currentState->value,
$targetState->value
)
);
}
// 执行实际的状态更新(例如数据库更新)
$order->setState($targetState);
// 可以在这里触发领域事件,如 OrderConfirmedEvent
}
}
外部业务代码变得非常简单:
$stateMachine = new OrderStateMachine();
try {
$stateMachine->transition($order, OrderState::Confirmed);
} catch (InvalidStateTransitionException $e) {
// 处理非法状态迁移,比如返回给前端错误提示
throw new BusinessException($e->getMessage());
}
这样一来,状态流转规则不再散落在各个控制器或Service的杂七杂八判断里,而是全部收拢到了OrderState枚举和OrderStateMachine类中。整个模块的职责清晰到让人舒适。
三、给状态加上行为——执行进入状态时的副作用
很多时候,状态迁移不仅仅是改一个字段值,还需要执行一些额外操作。比如订单变成“已发货”时,需要写一条物流记录并给用户发通知;变成“已取消”时,需要释放库存。这些行为如果也写在状态机里,代码会更加内聚。
枚举支持实现接口,我们可以定义一个StateBehaviour接口,让每个状态实现自己的进入逻辑:
interface OnEnterState {
public function onEnter(Order $order): void;
}
enum OrderState: string implements OnEnterState {
// ... 枚举成员定义
case Shipped = 'shipped';
case Cancelled = 'cancelled';
// ...
public function onEnter(Order $order): void {
match($this) {
self::Shipped => $this->handleShipped($order),
self::Cancelled => $this->handleCancelled($order),
default => null, // 多数状态不需要特殊副作用
};
}
private function handleShipped(Order $order): void {
// 记录物流信息
ShipmentLog::create([
'order_id' => $order->id,
'status' => 'shipped',
'shipped_at' => now(),
]);
// 发送通知
event(new OrderShipped($order));
}
private function handleCancelled(Order $order): void {
// 释放库存
InventoryService::release($order->items);
// 发送取消通知
event(new OrderCancelled($order));
}
}
然后在OrderStateMachine里,状态更新成功后调用$targetState->onEnter($order)。这样状态相关的副作用也收拢到状态枚举自身里了。改动某个状态的进入行为,只需要修改对应的handleXxx私有方法,不用跨文件搜索。
有人可能会担心,枚举里调用外部的服务和事件会不会让枚举变得太重?这确实是一个需要权衡的点。我的做法是,如果副作用比较简单(例如只发一个事件),放在枚举里没问题;如果涉及复杂的跨服务调用,则保持OrderStateMachine的纯粹性,把副作用放在监听器或专门的Service里处理。两种方式可以并存,只要团队对边界有一个共识。
四、更复杂的场景——带条件的状态迁移
有些状态迁移不仅取决于当前状态,还取决于订单的某些属性。比如,已支付的订单只有金额大于零才能进入待发货状态(金额为零可能是赠品订单,直接变成已完成),或者已发货的订单只有签收时间超过7天才能变成已完成。这些条件该怎么融入枚举状态机?
可以在allowedTransitions中不仅返回目标状态列表,还返回一个可选的检查条件。一种做法是让每个状态提供一个检查函数,但枚举方法本身无法额外传递参数(比如订单对象),这时需要把判断逻辑提升到OrderStateMachine中,但依然依赖枚举提供的元数据。
我们可以在枚举里定义一个guardClosure,返回一组条件检查:
enum OrderState: string {
// ...
/**
* 返回迁移的目标状态及对应的守卫条件(如果有)
* @return array<OrderState, ?Closure(Order): bool>
*/
public function transitions(): array {
return match($this) {
self::Confirmed => [
self::Shipped => fn(Order $order) => $order->total_amount > 0,
self::Cancelled => null, // 无额外条件
],
self::Shipped => [
self::Delivered => fn(Order $order) =>
$order->shipped_at && $order->shipped_at->diffInDays(now()) >= 7,
],
// 其他状态...
default => [],
};
}
}
然后在OrderStateMachine里使用:
public function transition(Order $order, OrderState $target): void {
$transitions = $order->getState()->transitions();
if (!array_key_exists($target, $transitions)) {
throw new InvalidStateTransitionException('非法状态迁移');
}
$guard = $transitions[$target];
if ($guard !== null && !$guard($order)) {
throw new InvalidStateTransitionException('不满足迁移条件');
}
// 更新状态并执行onEnter...
}
这样,带条件的迁移也整合进了状态机里。条件的闭包写在枚举里,但执行时接受订单实例,既利用了枚举的聚合优势,又保留了灵活性。
五、实际项目中的落地经验
将这套枚举状态机方案应用到真实项目后,有几个体会特别深。
1. 状态的名字尽量贴近业务术语
枚举成员的命名和字符串值最好用领域专家的用语,比如Confirmed而不是Payed,因为不同业务里“支付”可能不是一个独立状态。直接和产品经理对齐状态名,减少翻译成本。
2. 始终通过状态机来修改状态
全局约定“订单状态只能通过OrderStateMachine来修改”,不允许任何地方直接调用$order->setState()。这要依靠代码审查和团队规范来保障。一旦出现绕过状态机的直接赋值,整个状态流转的一致性就会被破坏。
3. 数据库字段存储枚举值
数据库status字段仍然存储字符串(如confirmed),读出来后在模型层转换为枚举实例。可以在Eloquent模型里做属性转换:
protected $casts = [
'status' => OrderState::class,
];
这样从数据库读出的字符串会自动变成OrderState实例,写回时也会自动转为字符串。全程类型安全。
4. 处理废弃的状态值
需求变更后某些状态可能不再使用,但数据库里还存在旧数据。枚举中的废弃case可以保留,但需要在allowedTransitions里返回空数组,并标记为@deprecated。数据修缮脚本可以在时机成熟时统一迁移遗留数据。
六、总结
用PHP枚举搭建状态机,本质上是在用一种更严格、更聚合的方式表达业务规则。枚举的类型安全性消灭了低级错误,match表达式的引入让状态流转图直接可读,而方法封装让状态相关的行为不再四分五裂。这个方案并不复杂,也不需要引入第三方库,PHP 8.1本身已经提供了足够的语法糖。
如果你的项目还在用字符串或常量数组管理状态,不妨挑一个中等复杂度的模块先尝试重构一下。把状态值换成枚举,把流转规则写进match,你会立刻感到代码的清晰度提升了一个档次。而且这种清晰度不是依赖于注释和文档,而是由解释器强制保障的——这才是最让人安心的地方。

