$readonly仅过滤字段不校验状态合法性,无法阻止非法回退或跨状态跳转;必须结合状态机校验(如beforeupdate钩子中验证流转规则)与写入拦截(如禁用直接赋值、强制专用方法)双保险。

订单状态字段不能只靠 $readonly 保护——它拦不住非法回退,也拦不住跨状态跳转,真要防住,得用状态机校验+写入拦截双保险。
为什么 $readonly = ['status'] 对订单状态无效
ThinkPHP 的 $readonly 只在 save() 和 update() 组装数据时过滤字段,但它不关心值是否合法:传 ['status' => 'paid'] 还是 ['status' => 'cancelled'],只要字段名在数组里,就直接丢掉,连比对都不做。这意味着:
- 用户 POST
status=shipped到一个已是delivered的订单,$readonly不报错、不拦截,只是默默忽略——但业务上这属于非法回退 - 如果接口允许部分字段更新(如 PATCH),而你又没显式剔除
status,前端绕过前端校验直发请求,$readonly依然无感 -
User::update($data)这类静态调用完全不触发$readonly,字段照写不误
真正起效的状态变更控制:在 beforeUpdate 钩子里做状态机校验
必须把状态合法性判断放在数据落库前最后一环,且要覆盖所有模型写入路径。推荐在模型中定义明确的流转规则,并在钩子中强制校验:
- 定义合法转移表,比如:
protected $statusTransitions = ['pending' => ['paid', 'cancelled'], 'paid' => ['shipped', 'refunded']] - 在
beforeUpdate中检查:if (isset($data['status']) && !in_array($data['status'], $this->statusTransitions[$this->status] ?? [])) { throw new Exception('非法状态变更'); } - 注意:
$this->status是当前数据库值,不是请求里的旧值;若用update()批量更新多个记录,需改用查询后逐条校验 - 别忘了软删除场景:当
delete_time被设为非 null,status应禁止再变更为任何非终态(如cancelled)
关联模型更新时,status 更容易被意外修改
用 with('order') 预载入订单后,再执行 $user->order->status = 'refunded'; $user->order->save();,会直接触发订单模型的完整生命周期——此时 $readonly 生效,但状态机校验若没在订单模型里定义,就彻底失效。
- 预载入的关联模型是“活对象”,
save()会走它自己的钩子和校验逻辑,跟主模型无关 - 务必确保每个含
status的模型都独立实现状态机,不能依赖父类或全局配置 - 更安全的做法是禁用直接赋值:在订单模型的
setStatusAttr里抛异常,强制走专用方法,如refund()或ship() - 如果必须支持多模型协同变更(如退款同时更新用户余额),用事务封装,而不是靠关联模型链式调用
缓存与状态不一致时,$readonly 完全不帮忙
订单详情页用了缓存,但后台运营手动 SQL 更新了 status,缓存未失效——这时前端看到的还是旧状态。而 $readonly 既不参与读取逻辑,也不感知缓存生命周期。
-
$readonly是纯写入层机制,和缓存系统零耦合 - 状态变更后,必须主动清除相关缓存,例如:
Cache::tag('order_status')->clear()或按 ID 清单精准删除 - 如果用了
cache(true)加关联缓存,记得在状态变更钩子里同步清理,否则 with('log') 查到的可能是旧状态下的操作日志 - 最易忽略的一点:事务内先改状态再查缓存,大概率命中旧值——此时应临时禁用缓存,或用
fresh()强制重查
状态字段的防护核心从来不在“能不能写”,而在“该不该这么写”。$readonly 是个开关,状态机才是规则书;漏掉其中任一环,非法回退就只是时间问题。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











