先说个真事。上个月排查一个订单列表白屏的问题,最后定位到数据库里有一条 status 等于 3 的记录,而前端拿到之后去映射“已发货”的图标,映射表里根本没有 3,直接抛了个 undefined。翻代码发现是某个临时脚本为了赶进度,手动把状态改成了 3,但它其实想表达的是“已付款”。
这类问题靠加注释是治不住的。这次索性把订单状态整条链路改了一遍:用 PHP 8.1 的枚举定义状态,用 think-orm 的获取器修改器保证模型层拿到的一定是枚举,再写一个小状态机收敛流转规则,最后挂到模型事件上,让非法变更在入库前就报错。整个过程不复杂,代码加起来一百多行。
一、先看看原来的写法
改造之前,项目里到处是这种东西:
// 控制器里
if ($order->status == 1) {
// 发货
}
// 服务层里
Order::where('status', '<>', 4)->update(['status' => 9]);
// 前端返回
return json(['status' => $order->status, 'status_text' => '已付款']);
问题有三个。第一,数字本身没有语义,3 到底是“已发货”还是“已付款”,得去翻文档或者看注释。第二,没有地方能拦住非法变更,只要有人写了个 update 语句,已取消的订单也能被改成已完成。第三,接口返回的时候要手动拼 status_text,一旦漏了前端就得自己映射,映射表还容易过期。
二、用枚举把状态定义收敛
PHP 8.1 的枚举正好适合这种场景。新建 app/enum/OrderStatus.php:
<?php
namespace appenum;
enum OrderStatus: int
{
case Pending = 1;
case Paid = 2;
case Shipped = 3;
case Completed = 4;
case Cancelled = 9;
public function label(): string
{
return match ($this) {
self::Pending => '待付款',
self::Paid => '已付款',
self::Shipped => '已发货',
self::Completed => '已完成',
self::Cancelled => '已取消',
};
}
public function allowedNext(): array
{
return match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Completed],
self::Completed => [],
self::Cancelled => [],
};
}
}
这里用了 backing type 为 int 的枚举,因为数据库字段是 tinyint。label() 返回中文,allowedNext() 描述允许流转到哪些状态。两个方法放在枚举里而不是单独的服务类,是因为它们跟状态本身强相关,放在一起改起来方便。
注意 allowedNext() 的返回值是枚举数组,不是 int 数组。后面做判断时要用 in_array($to, $from->allowedNext(), true),第三个参数别省,枚举比较必须用严格模式。
三、让模型吐出来的一定是枚举
关键的一步来了。数据库里存的还是 tinyint,但模型层要保证业务代码拿到的永远是 OrderStatus 实例。用获取器做入向转换,修改器做出向转换。新建 app/model/Order.php:
<?php
namespace appmodel;
use appenumOrderStatus;
use thinkModel;
class Order extends Model
{
protected $name = 'order';
protected $autoWriteTimestamp = true;
public function getStatusAttr($value): OrderStatus
{
if ($value instanceof OrderStatus) {
return $value;
}
return OrderStatus::from((int) $value);
}
public function setStatusAttr($value): int
{
if ($value instanceof OrderStatus) {
return $value->value;
}
return (int) $value;
}
public function getStatusTextAttr(): string
{
return $this->status->label();
}
}
获取器里的类型判断不能省。ThinkPHP 8 的模型在 save() 之后不会重新从数据库读值,内存里的 data 数组此时已经是 int,如果获取器直接 from() 也没问题,但像 toArray() 这类调用在某些情况下会把枚举对象再传回获取器,多一层判断更稳。
status_text 这个虚拟字段是给前端用的。toArray() 输出时会自动带上 status_text,前端就不用再维护一份映射表了。要隐藏某些字段用 $hidden 就行,这个不影响。
顺便说一句,think-orm 3.x 的 $type 属性其实也支持直接写枚举类名,写法是 protected $type = ['status' => OrderStatus::class];。效果跟手写获取器差不多,但我在查询条件那里吃了亏,后面坑里细说。手写获取器更透明一点,出了问题好排查。
四、把流转规则收进状态机
有了枚举之后,状态流转的判断可以写得很短。新建 app/service/OrderStateMachine.php:
<?php
namespace appservice;
use appenumOrderStatus;
use appexceptionOrderStatusException;
class OrderStateMachine
{
public static function transit(OrderStatus $from, OrderStatus $to): void
{
if ($from === $to) {
throw new OrderStatusException(
sprintf('订单状态已经是 %s,无需重复变更', $from->label())
);
}
if (!in_array($to, $from->allowedNext(), true)) {
throw new OrderStatusException(
sprintf(
'订单状态不允许从 %s(%d) 变更为 %s(%d)',
$from->label(), $from->value,
$to->label(), $to->value
)
);
}
}
}
异常类 OrderStatusException 继承 RuntimeException 就行,不必贴代码了。有了这个类,业务代码里想改状态之前调一下 transit(),非法流转会立刻抛异常,比等到数据库里出现脏数据再回头查要省事得多。
五、挂到模型事件上兜底
光靠业务代码里手动调 transit() 还是可能漏。更稳的做法是在模型更新前自动检查一遍。在 Order 模型里加一个 onBeforeUpdate 静态方法:
protected static function onBeforeUpdate(self $model): void
{
if (!$model->isDirty('status')) {
return;
}
$origin = $model->getOrigin('status');
if ($origin === null) {
return;
}
$from = OrderStatus::from((int) $origin);
$to = $model->status;
OrderStateMachine::transit($from, $to);
}
几个细节需要注意。getOrigin() 取的是模型加载时的原始值,不会走获取器,所以拿到的是 int,需要手动 from() 转一下。isDirty('status') 判断字段有没有被改过,没改就直接返回,避免不必要的检查。$model->status 走的是获取器,此时已经是枚举实例。
这个钩子的好处是,只要是通过模型 save() 或 update() 走的更新,都会被拦一道。哪怕某个同事对状态机的存在一无所知,他也改不出非法状态。
六、查询条件别直接把枚举传进去
拿到枚举之后很容易顺手写成这样:
// 错误写法
$list = Order::where('status', OrderStatus::Paid)->select();
这段代码会抛出类似 “Object of class OrderStatus could not be converted to string” 的错误,因为查询构造器最终要把参数绑给 PDO,PDO 不认识枚举对象。正确写法是取 value:
// 正确写法
$list = Order::where('status', OrderStatus::Paid->value)->select();
// 取状态集合也可以用
$pendingAndPaid = array_map(
fn (OrderStatus $s) => $s->value,
[OrderStatus::Pending, OrderStatus::Paid]
);
$list = Order::whereIn('status', $pendingAndPaid)->select();
如果确实觉得每次写 ->value 麻烦,可以在枚举里加一个静态方法返回所有 int 值,比如 OrderStatus::values(),但查询条件那处还是得显式取值,这个躲不掉。
七、验证器里也能用上
后台管理有个接口允许运营手动改订单状态,参数是 status=3 这种数字。以前是写个 in:1,2,3,4,9 的规则,加一个状态就得改一次规则。现在可以这样:
use appenumOrderStatus;
use thinkValidate;
$validate = new Validate([
'order_no' => 'require',
'status' => 'require|integer',
]);
$data = $this->request->only(['order_no', 'status']);
if (!$validate->check($data)) {
return json(['code' => 400, 'msg' => $validate->getError()]);
}
$target = OrderStatus::tryFrom((int) $data['status']);
if ($target === null) {
return json(['code' => 400, 'msg' => '无效的订单状态']);
}
// 后续用 $target 去走状态机
$order = Order::where('order_no', $data['order_no'])->findOrFail();
OrderStateMachine::transit($order->status, $target);
$order->status = $target;
$order->save();
tryFrom() 是 backed enum 自带的,传入非法整型返回 null,不会抛异常,适合这种需要自己控制错误信息的场景。枚举扩展新状态时,验证代码一行都不用动。
八、四个实际踩到的坑
批量更新绕过了模型事件。像 Order::where('status', 1)->update(['status' => 9]) 这种写法直接生成 SQL,不经过任何模型事件,状态机的检查形同虚设。项目里如果允许这种写法,得自己包一层服务方法,在里面先查出来再用模型更新,别图快。
查询表达式里的枚举不会自动转成值。前面提过一次,这里再强调一下,where('status', OrderStatus::Paid) 会报错,必须写 ->value。这个问题不会在开发阶段暴露,往往要到某条特定路径跑起来才发现,建议在代码评审时专门看一眼查询条件。
JSON 输出时要确认格式。ThinkPHP 8 的 json() 响应在遇到枚举对象时,较新的版本会输出其 value,但如果项目里还是老版本,可能会输出成对象形式。稳妥的办法是在 toArray() 回来之后手动处理一下,或者直接依赖前面定义的 status_text,让前端只用文本字段。
新加枚举值时要同步改状态机。最大的隐患其实不在代码里,而在流程上。只要有人往枚举里加了一个新 case,却没更新 allowedNext(),那个状态就永远流转不出去。建议在枚举里给新加 case 的地方留一行注释,或者干脆在 allowedNext() 的 match 里不写 default,让 PHP 自己报错提醒漏了分支。
九、改完之后
收益还是比较明显的。控制器和前端之间少了一份状态映射表,接口返回里直接带 status_text,前端不用维护。后台手动改状态那个接口,非法请求会在验证阶段就被拒掉,不会像以前那样先写进数据库再靠人工排查。最直接的一个变化是,之前那个临时脚本如果再想改状态,代码在 save() 这一步就抛异常了,压根落不了库。
改动量其实不大,枚举加状态机不到八十行,模型里多了三个方法。如果你手上也有这种“数字状态满天飞”的项目,不用一次全换,挑一个核心模型先试,把获取器、状态机和模型事件这套走通,后面复制到别的模型上会很快。

