eloquent 无原生“深历史”支持,因其 $casts、访问器等仅作用于当前实例,不记录变更轨迹;审计需借助 laravel-activitylog 的 tapactivity 预加载关联并序列化快照,还原须事务性分步校准关联状态。

为什么 Eloquent 没有原生的“深历史”支持
因为 Eloquent 的 $casts、getFooAttribute 和 setFooAttribute 都只作用于当前模型实例,不记录变更轨迹;getAttributeValue 返回的是当前值,不是历史快照。所谓“深历史”,比如嵌套关系中 User→Post→Comment 某个字段在 3 天前的值,Laravel 默认连单层模型的历史都不存,更别说跨关联的完整状态回溯。
常见错误现象:withTrashed() 只恢复软删除状态,不还原字段值;用 retrieved 事件监听取值,但没存上下文时间戳和关联链;手动序列化整个模型再存,结果 Carbon 实例、Collection 或闭包导致 serialize() 失败。
- 真正需要“深历史”的场景:合规审计(如 GDPR 数据修改留痕)、业务状态机回滚(订单从「已发货」回退到「待付款」时,需同步还原收货地址、优惠券使用状态等关联数据)
- 不要试图给每个
Attribute加booted钩子去打点——它无法区分是初始化加载还是主动赋值,容易重复记录 -
static::updating()事件能捕获变更,但拿不到旧值(除非先查一次),且对批量更新(update())无效
用 laravel-activitylog + 自定义 tap 实现带关系快照的记录
官方推荐的 spatie/laravel-activitylog 默认只记主模型变更,但它的 tapActivity 方法允许你注入任意逻辑,包括预加载并序列化关联数据。关键不是“记什么”,而是“在哪个时机记”。必须在 saving 之前、且模型已加载必要关系后触发。
实操建议:
- 在模型中定义
protected $auditInclude = ['name', 'status'];,仅审计明确字段,避免大文本或二进制字段拖慢性能 - 重写
tapActivity,用$this->load('post.comments')显式加载需要回溯的深层关系(注意:不能用with,因为那是查询时加载,而这里是变更时快照) - 把关联数据转成数组时,调用
toArray()前先检查是否已加载:if ($this->relationLoaded('post')) { ... },否则会触发 N+1 查询 - 存储时用
json_encode($snapshot, JSON_UNESCAPED_UNICODE),避免中文被转义,且确保Carbon被转为 ISO8601 字符串(toArray()默认处理)
示例片段:
public function tapActivity(Activity $activity)
{
$activity->properties = array_merge(
$activity->properties,
['deep_snapshot' => $this->buildDeepSnapshot()]
);
}
protected function buildDeepSnapshot()
{
return [
'user' => $this->only(['id', 'email']),
'post' => $this->post?->only(['id', 'title', 'published_at']),
'comments_count' => $this->post?->comments()->count(),
];
}
restore() 时如何安全还原“深状态”而不破坏一致性
直接调用 $model->restore() 只恢复本表的 deleted_at,不会自动写回历史字段值,更不会同步更新关联模型。真正的还原必须是事务性的:主模型字段回填 + 关联模型状态校准 + 外键约束验证。
容易踩的坑:
- 用
fill()批量赋值历史字段,但没排除created_at、updated_at等时间戳字段,导致后续save()触发时间戳覆盖 - 还原
Post状态时,没检查其所属User是否已被软删除,强行关联会导致外键异常 - 对
Comment数组做sync()还原,但未处理已删除的评论 ID,抛出ModelNotFoundException
正确做法是分三步走:
- 从历史记录中取出
deep_snapshot,用Arr::except($snapshot, ['created_at', 'updated_at'])过滤敏感字段 - 用
DB::transaction()包裹整个还原过程,先更新主模型,再按依赖顺序更新关联(User → Post → Comment) - 对每个关联模型,先
findOrNew(),再fill()并saveQuietly()(跳过事件,避免循环触发新日志)
性能与存储权衡:什么时候该放弃“完整状态回溯”
每条变更记录存 50KB 深快照,日均 1 万次操作,一个月就是 15GB。MySQL 的 JSON 字段查询极慢,WHERE JSON_CONTAINS(deep_snapshot, '"draft") 几乎不可用。不是所有字段都需要深历史。
判断依据很实际:
- 如果只是查「某用户某条评论在上周三的审核状态」,用独立的
comment_histories表(含comment_id、status、changed_at)比存全量快照高效 10 倍 - 对
json类型字段(如用户配置preferences),只记录 diff(用jsondiffpatch库),而非整个结构 - Laravel 11+ 的
SchemalessAttributes可配合cast: array实现字段级变更追踪,比模型级更轻量
最常被忽略的一点:深历史的价值取决于「回溯频率」。如果过去半年只有 2 次人工审计需求,花两周实现全自动深回溯,不如写个临时 Artisan 命令,从备份库拉指定时间点的 dump 再解析。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











