订单是电商系统里最核心的业务对象,一个订单从用户下单开始,要经历待支付、已支付、待发货、已发货、已完成、已取消等一系列状态。不少PHP项目在处理这些流转时,习惯在业务代码里写满if和else,判断当前状态是不是允许做某个操作。时间一长,这段代码会膨胀到几百行,改一个状态要全局搜索调用点,稍不留神就出现非法状态跳转,比如已取消的订单又被发货了。状态机(State Machine)正是解决这个问题的经典方案,而状态模式(State Pattern)则是它在面向对象语言里的标准实现方式。本文会从概念讲到代码,带你完整实现一套PHP订单状态机。

一、什么是订单状态机,为什么需要它
状态机的核心思想很简单:一个对象在任意时刻只能处于有限个状态中的一个,状态的改变只能由特定的事件触发,并且每次跳转都遵循预先定义好的规则。比如订单在待支付状态下,可以触发支付事件进入已支付状态,也可以触发取消事件进入已取消状态,但绝不能直接跳到已发货状态。
把这些规则显式地定义出来,就形成了状态机。它的价值主要体现在三个方面。第一是约束合法性,任何不符合规则的操作都会被直接拦截,从根源上杜绝脏数据。第二是集中管理,所有的流转规则都在一个地方维护,排查问题时一目了然,不用在散落各处的业务代码里翻找。第三是易于扩展,新增一个状态或者一条流转规则,只需要修改状态机的定义,不影响其他部分。
如果不使用状态机,典型的写法类似下面这样:
public function ship($orderId)
{
$order = Order::find($orderId);
if ($order->status == 'paid') {
// 发货逻辑
$order->status = 'shipped';
} elseif ($order->status == 'pending') {
throw new Exception('订单未支付,不能发货');
} else {
throw new Exception('当前状态不允许发货');
}
}这种写法在状态少的时候还能接受,一旦状态超过五六个,加上各种事件组合,分支数量会呈爆炸式增长。而且判断逻辑散落在支付回调、后台管理、定时任务等多个入口,很难保证一致性。状态机就是把这些判断逻辑收拢的正规手段。
二、用状态模式实现订单状态机的PHP代码
状态模式是GoF二十三种设计模式之一,它的做法是把每个状态封装成独立的类,环境类(这里是订单)持有当前状态对象的引用,所有行为都委托给状态对象处理。这样每个状态的流转规则只写在对应的类里,职责单一,符合开闭原则。下面是完整的实现。
先定义状态接口,约定每个状态必须实现的方法:
interface OrderState
{
// 支付
public function pay(OrderContext $order);
// 取消
public function cancel(OrderContext $order);
// 发货
public function ship(OrderContext $order);
// 确认收货
public function confirm(OrderContext $order);
}然后编写上下文类,也就是订单本身,它负责维护当前状态并转发请求:
class OrderContext
{
private OrderState $state;
public function __construct(OrderState $state)
{
$this->state = $state;
}
public function getState(): OrderState
{
return $this->state;
}
public function setState(OrderState $state): void
{
$this->state = $state;
}
public function pay()
{
$this->state->pay($this);
}
public function cancel()
{
$this->state->cancel($this);
}
public function ship()
{
$this->state->ship($this);
}
public function confirm()
{
$this->state->confirm($this);
}
}接着实现几个具体的状态类。以待支付状态为例:
class PendingState implements OrderState
{
public function pay(OrderContext $order)
{
echo "支付成功,订单进入已支付状态\n";
$order->setState(new PaidState());
}
public function cancel(OrderContext $order)
{
echo "订单已取消\n";
$order->setState(new CancelledState());
}
public function ship(OrderContext $order)
{
throw new RuntimeException('待支付订单不允许发货');
}
public function confirm(OrderContext $order)
{
throw new RuntimeException('待支付订单不允许确认收货');
}
}已支付状态则只允许发货或取消:
class PaidState implements OrderState
{
public function pay(OrderContext $order)
{
throw new RuntimeException('订单已支付,请勿重复支付');
}
public function cancel(OrderContext $order)
{
echo "已支付订单申请退款并取消\n";
$order->setState(new CancelledState());
}
public function ship(OrderContext $order)
{
echo "发货成功\n";
$order->setState(new ShippedState());
}
public function confirm(OrderContext $order)
{
throw new RuntimeException('未发货订单不允许确认收货');
}
}使用方式非常直观,客户端代码完全不需要关心当前是什么状态:
$order = new OrderContext(new PendingState()); $order->pay(); // 支付成功,订单进入已支付状态 $order->ship(); // 发货成功 $order->pay(); // 抛出异常:订单已支付,请勿重复支付
三、轻量级方案:用数组定义状态流转表
状态模式虽然优雅,但为每个状态建一个类,在状态较多时类的数量会不少。如果项目规模不大,可以用更轻量的方式:把流转规则集中定义在一个二维数组里,形成状态流转表。这种方式本质上还是状态机,只是表达形式不同。
class OrderStateMachine
{
// 定义合法的状态流转:当前状态 => [事件 => 目标状态]
private array $transitions = [
'pending' => [
'pay' => 'paid',
'cancel' => 'cancelled',
],
'paid' => [
'ship' => 'shipped',
'cancel' => 'cancelled',
],
'shipped' => [
'confirm' => 'completed',
],
'completed' => [],
'cancelled' => [],
];
public function can(string $current, string $event): bool
{
return isset($this->transitions[$current][$event]);
}
public function apply(string $current, string $event): string
{
if (!$this->can($current, $event)) {
throw new RuntimeException(
sprintf('非法操作:状态%s下不允许执行%s', $current, $event)
);
}
return $this->transitions[$current][$event];
}
}调用时先校验再更新,代码简洁且规则一目了然。这个流转表还可以配合数据库持久化,把状态变更记录到一张日志表里,方便追溯每一次跳转的时间、操作人和触发事件,这在排查线上纠纷时非常有用。
四、数据库层面的并发安全处理
状态机在内存里拦住了非法跳转,但线上环境往往是多进程并发的。比如用户正在取消订单,后台管理员同时点了发货,两个请求都读到订单是已支付状态,各自通过校验后同时写库,就可能出现已取消订单被标记为已发货的脏数据。解决方法是在更新语句上做条件限制,利用数据库行锁保证原子性。
// 传统写法:先查询再更新,存在竞态窗口
$order = Order::find($orderId);
if ($stateMachine->can($order->status, 'ship')) {
$order->status = 'shipped';
$order->save();
}
// 推荐写法:带条件的原子更新
$affected = Order::where('id', $orderId)
->where('status', 'paid') // 只有仍是已支付状态时才更新
->update(['status' => 'shipped']);
if ($affected === 0) {
throw new RuntimeException('状态已变更,操作失败,请刷新后重试');
}关键就在where('status', 'paid')这个条件上。更新语句执行时,MySQL会对匹配的行加排他锁,如果另一个事务已经改过状态,本次更新的影响行数就是0,业务层据此判断发生了并发冲突。这种方式不需要额外的锁表操作,性能开销极小,是订单系统处理并发的标准做法。
另外建议把状态变更和业务操作放在同一个数据库事务里,比如发货时既要更新订单状态,也要扣减库存、生成物流单,这些操作必须同成功同失败,否则数据会不一致。配合上面的原子更新,一套完整可靠的订单流转方案就成型了。
五、方案对比与选型建议
状态模式和流转表各有适用场景。状态模式适合状态行为差异大、每个状态下都有复杂业务逻辑的系统,比如取消时要区分是否已支付走不同退款流程,此时每个状态类里可以写完整的处理代码,结构清晰。流转表适合规则规整、行为相对简单的场景,胜在轻量,一张表就能让所有人看懂全部流转规则。
如果项目使用Laravel或Symfony框架,也可以考虑现成的状态机库,例如Symfony Workflow组件,它支持状态机和工作流两种模式,可以通过配置文件定义状态和事件,还能触发进入状态、离开状态等钩子事件,适合复杂度较高的系统。但对于大多数中小型项目,自建一套流转表方案几十行代码就能搞定,没有必要引入额外依赖。
无论选择哪种方案,核心原则是一致的:流转规则必须集中定义、非法跳转必须在入口拦截、数据库更新必须保证并发安全。把这三点做扎实,订单模块的稳定性就有了坚实保障,后续新增状态或调整流程也只是修改规则定义的工作量而已。