php订单状态机应采用状态类隔离设计,每个状态(如pendingstate、paidstate)实现统一接口,通过cantransitionto()控制合法流转,禁止直接赋值status字段,确保状态变更安全可控。

订单状态变更总被 if-else 堆出 bug?用 State 类隔离每种状态逻辑
直接说结论:PHP 订单状态机不该靠一串 if/elseif 或 switch 控制流转,而应让每个状态(如 'pending'、'paid'、'shipped')变成独立类。这样新增状态、改校验规则、加日志或通知,都不用动其他状态的代码。
常见错误是把所有状态判断和动作写在 Order::updateStatus() 里,结果一加「已取消后可退款」逻辑,就得在十几个分支里补条件,漏一个就导致状态倒流或跳过校验。
- 每个状态类实现统一接口(比如
OrderStateInterface),必须定义canTransitionTo($next)和handle(Order $order) -
Order实例只持有一个$state属性,类型是具体状态类(如PendingState),不存字符串 - 状态切换必须调用
$order->transitionTo(new PaidState()),禁止直接赋值$order->status = 'paid'
怎么防止「已发货」又退回「待支付」这种非法跳转?
关键不是靠数据库字段限制,而是把合法转移路径写进每个状态类的 canTransitionTo() 方法里。比如 ShippedState::canTransitionTo() 只返回 true 给 DeliveredState 和 ReturnedState,对 PaidState 直接返回 false。
别在控制器里硬编码判断:if ($order->status === 'shipped' && $newStatus === 'paid') { throw ... }——这等于把状态图散落在各处,没人知道全貌。
立即学习“PHP免费学习笔记(深入)”;
- 所有允许的流转关系集中管理:要么在每个状态类里声明,要么用配置数组(如
['shipped' => ['delivered', 'returned']]),但必须由状态类统一读取校验 - 数据库字段
status仅作快照或查询用,不参与流转决策;真正驱动流程的是内存中的状态对象 - 测试时直接 new 两个状态类,调
canTransitionTo()就能验证路径,不用启整个订单流程
setState() 被意外调用怎么办?得封死非受控入口
很多团队加了状态类,但留着 Order::setStatus($string) 这种方法,结果业务代码里还是到处 $order->setStatus('cancelled'),绕过所有校验和钩子。
这不是设计问题,是权限控制没做实。PHP 没有 final property,但可以靠约定+工具双重卡住:
-
Order::$state设为private,只提供transitionTo(StateInterface $newState)入口 - 在
transitionTo()内部调用当前状态的canTransitionTo(),不通过就抛InvalidStateTransitionException - 用 PHPStan 或 Psalm 配置检查,禁止任何地方调用类似
setStatus、setStat这样的方法名(正则匹配)
为什么不用 Laravel 的 spatie/laravel-model-states?
它确实省事,但默认把状态存在数据库字符串字段里,且流转逻辑容易退化成配置数组。你得手动重写 allowTransitionTo() 方法,否则照样挡不住非法跳转。
更麻烦的是,它把「状态行为」和「模型本身」耦太紧——比如「已付款」要发短信、「已发货」要调物流 API,这些本该在状态类里封装,但它倾向让你在模型里写一堆 whenPaid() 回调。
- 如果你项目已重度依赖 Eloquent,且状态流转简单(≤5 种状态、无复合条件),它够用
- 但只要涉及多角色审批、超时自动降级、或需要按状态聚合统计,手写状态类反而更可控——因为你能随时在
PaidState::handle()里加事务、队列、外部调用 - 注意:它的
State基类不是 final,但子类若没重写transitionTo(),就会走默认宽松逻辑,这点极易被忽略
状态机最难的从来不是写几个类,而是让所有人——包括新来的同事——不敢也不需要绕过它。约束力比灵活性重要。











