thinkphp 8.0的$readonly是模型层写入过滤器,仅在模型实例的save()/update()中生效;必须满足声明为protected static、字段名与数据库列名严格一致、且走实例调用路径三个条件才真正起效。

ThinkPHP 8.0 的 $readonly 不是数据库锁,而是模型层的一道“写入过滤器”——它只在模型实例调用 save() 或 update() 时起作用,目的是防止业务代码误改关键字段,而非抵御恶意攻击或绕过行为。
只读字段怎么写才真正生效
必须满足三个硬性条件,缺一不可:
-
声明方式正确:在模型类中定义为
protected static $readonly = ['create_time', 'delete_time'];,不能是普通属性、不能漏掉static、不能用public或private -
字段名完全匹配:必须与数据库列名大小写、下划线严格一致。比如数据库里是
pay_status,就不能写成payStatus或PAY_STATUS -
走对调用路径:仅对
$model->update($data)或$model->save()生效;User::update($data)静态调用、Db::name('user')->update()原生操作、$model->data($data, true)->save()强制赋值,全部绕过
哪些字段该设只读,哪些千万别碰
设错比不设更危险,核心看字段语义是否“写入即定型”:
-
必须设:
create_time(创建时间不可回退)、delete_time(软删除标记一旦置位不能清空)、source(来源标识如'wechat'或'admin',改了就断审计链) -
谨慎设:
user_id(后台支持转单时设只读会卡死流程)、order_no(补单重发可能需调整) -
别碰:
id(主键新增时框架自动忽略写入,加了反而干扰 UUID 或自增)、update_time(设只读后它真不会更新,时间就冻住了)
为什么写了 $readonly 还是被改了
不是框架失效,而是代码没走模型层——常见绕过点有四个:
- 用了
Db::name('order')->update(['pay_status' => 1]):原生 SQL 完全跳过模型,$readonly形同虚设 - 调了
$order->data($data, true)->save():第二个参数true表示强制赋值,跳过所有过滤(包括$readonly、$fillable、验证器) - 关联模型单独保存:
$order->user->status = 2; $order->user->save();:主模型的$readonly对关联模型无效 - 字段名写错:数据库列是
logistics_status,但$readonly写成['logisticsStatus']→ 字符串精确匹配失败,等于没设
比 $readonly 更早、更稳的防护手段
真要守住核心数据,得靠分层防御:
-
验签前置:在中间件里用
$this->request->rawInput()取原始请求体,排序 + rawurlencode + 剔除 sign/timestamp 后验签,非法请求在控制器前就被拦截 -
专用方法封装:把状态变更写成
$order->changePayStatus(2),内部校验当前状态是否允许流转、操作人是否有权限、记录完整审计日志 -
数据库硬约束:MySQL 8.0.16+ 支持
CHECK (pay_status IN (0,1,2)),非法 UPDATE 直接报错;对create_time可用GENERATED ALWAYS AS禁止显式赋值
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











