Eloquent 的 $casts 无法处理嵌套状态变更感知与响应,必须通过自定义 getAttribute/setAttribute 手动接管 JSON 字段的序列化、反序列化及 dirty 标记,否则深层修改不触发更新。

Eloquent 的 getAttribute 和 setAttribute 不会自动递归处理嵌套数组或对象状态,所谓“Composite States”必须手动控制序列化/反序列化逻辑,否则 json 字段读写会丢失结构或触发意外类型转换。
为什么直接在 $casts 里写 'status' => 'array' 不够用
当你的状态需要分层(比如 ['payment' => ['status' => 'paid', 'at' => '2024-05-01'], 'shipping' => ['status' => 'pending']]),仅靠 'array' 铸造会让 Eloquent 把整个字段当纯数组处理,无法拦截读写、无法校验子状态、无法响应变更(如 payment.status 改变时触发事件)。
常见错误现象:Model::find(1)->status['payment']['status'] = 'refunded'; $model->save(); 看似改了,但下次查出来还是旧值——因为 Eloquent 没感知到这个深层修改,isDirty() 返回 false,save() 实际没执行更新。
- 使用场景:订单多阶段状态(支付、发货、售后)、用户权限组合(role + permissions + scopes)、IoT 设备嵌套运行态
-
$casts只做基础类型转换,不提供访问器/修改器的嵌套钩子 - 性能影响:每次读取都
json_decode整个结构;若只改一个子字段却重存全量,IO 和 DB 日志开销翻倍
用 get{Attribute}Attribute + set{Attribute}Attribute 手动接管复合状态
核心是把整个 JSON 字段当作“容器”,用访问器返回封装好的状态对象(或带方法的数组),用修改器确保任何变更都落到原始字段上,并标记为 dirty。
立即学习“PHP免费学习笔记(深入)”;
示例:定义一个 StatusState 类封装逻辑,再在 Model 中桥接:
class Order extends Model
{
protected $casts = [
'status_json' => 'string', // 显式设为 string,避免 Laravel 自动 json_decode
];
public function getStatusAttribute()
{
if (!isset($this->attributes['status_json'])) {
return new StatusState([]);
}
return new StatusState(json_decode($this->attributes['status_json'], true) ?: []);
}
public function setStatusAttribute($value)
{
$this->attributes['status_json'] = json_encode($value instanceof StatusState ? $value->toArray() : $value);
$this->markAsDirty('status_json'); // 强制标记为 dirty
}
}
关键点:
-
getStatusAttribute返回对象而非数组,便于后续加->isPaid()、->getPaymentAt()等语义方法 -
setStatusAttribute必须显式调用$this->markAsDirty('status_json'),否则 Eloquent 不知道该更新字段 - 不要在访问器里直接改
$this->attributes,会破坏属性缓存和变更追踪
如何安全地更新嵌套字段而不丢状态
直接写 $order->status['payment']['status'] = 'shipped'; 不生效,因为 $order->status 是对象或数组副本,不是引用。必须通过 setter 或专用方法透出变更。
推荐做法:在 StatusState 类中提供 fluent 接口:
class StatusState
{
protected $data;
public function __construct(array $data) { $this->data = $data; }
public function setPaymentStatus(string $status): self
{
Arr::set($this->data, 'payment.status', $status);
return $this;
}
public function toArray(): array { return $this->data; }
}
然后这样用:
$order->status = $order->status->setPaymentStatus('shipped');
$order->save(); // 此时 status_json 已被标记为 dirty,且新值已 encode
容易踩的坑:
- 忘记在
set{Attribute}Attribute中调用markAsDirty→ 数据库不更新 - 在访问器里返回
json_decode(..., false)(对象)→ 后续Arr::set失败,必须用true得到数组 - 把复合状态字段名也写进
$fillable→ 允许批量赋值时绕过封装,直接塞 raw array,破坏一致性
兼容 Laravel 的 withAggregate 和查询作用域
数据库层面不能直接 whereJsonContains('status_json', ['payment.status' => 'paid'])(MySQL 5.7+ 支持,但 PostgreSQL 和 SQLite 不同),所以查询嵌套状态得靠原生 JSON 函数或冗余字段。
实操建议:
- 高频查询的子状态(如
payment.status)单独建冗余字段payment_status,用updating事件同步:$model->payment_status = $model->status['payment']['status'] ?? null; - 低频或管理后台用的复杂查询,用
whereRaw:whereRaw("JSON_EXTRACT(status_json, '$.payment.status') = ?", ['paid']) -
withAggregate不支持 JSON 路径聚合,需先selectRaw提取字段再聚合
真正难的不是怎么写访问器,而是决定哪些状态要嵌套、哪些该拆成独立字段——嵌套太深,查不出来;拆太散,业务逻辑散落在各处。状态设计一旦定型,改起来比迁移数据库还疼。



















