应自己写快照逻辑而非用 spatie/laravel-model-states,因其专为有限状态流转设计,不适用于全字段历史版本;正确做法是用 booted + updating 钩子,配合独立 snapshots 表存全量快照,并确保 JSON 字段类型、索引及敏感字段过滤。

用 spatie/laravel-model-states 还是自己写快照逻辑?
别硬套状态机库——spatie/laravel-model-states 是为「有限、明确的状态流转」设计的(比如 draft → pending_review → published),不是为全字段历史版本服务的。真要存每次变更的完整快照,它反而会把数据塞进 JSON 字段、丢失索引能力、查起来慢还难 debug。
更务实的选择是:用 Laravel 自带的 booted + static::updating() 钩子,配合一张独立的 model_snapshots 表,手动存变更前/后的字段差异或完整快照。
- 只在真正需要回滚或审计时才触发快照,避免无差别写入拖慢高频更新
- 快照表必须包含
model_type、model_id、snapshot_data(JSON)、updated_at,且给前三个字段建联合索引 - 别把
snapshot_data设成TEXT,用JSON类型(MySQL 5.7+ / PG)才能走原生 JSON 查询和校验
updating 钩子里怎么安全获取旧值?
Laravel 的 $model->getOriginal() 在 updating 钩子里能取到数据库里刚读出来的原始值,但前提是:模型必须是从 DB 查出来后、再调用 save() 的。如果走的是 update() 静态方法或批量更新,getOriginal() 会为空——这时钩子根本不会触发。
- 确保快照逻辑只绑定在实例的
updating事件上,而不是全局监听eloquent.updating: App\Models\Post - 想捕获
Post::where(...)->update(...)这类操作?做不到。这类 SQL 直接绕过模型生命周期,快照必须靠数据库 trigger 或应用层统一入口拦截(比如封装一个safeUpdate()方法) - 敏感字段(如
password、api_token)记得从快照里unset(),别留隐患
快照存全量还是差量?
存差量看着省空间,但实际增加复杂度:得比对新旧数组、处理嵌套关系、还原时还得合并历史差量——出错概率高,调试成本大。除非字段极多且变更极少,否则直接存全量 JSON 更可靠。
- 用
$model->getAttributes()拿当前值,$model->getOriginal()拿旧值,然后array_diff_assoc()出变更字段,但注意:null和空字符串、0 和false在 PHP 里容易误判,得用严格比较 - 更稳的做法是存两份全量:
before_snapshot和after_snapshot,字段名对齐,查某次修改“改了啥”时用 diff 工具看,不塞进代码逻辑里 - 如果模型用了
casts(比如'meta' => 'array'),快照前先调$model->toArray(),避免 JSON 字符串被二次 encode
查询某个模型的历史版本时性能卡在哪?
最常掉坑里的是:在控制器里循环查快照,比如 foreach ($post->snapshots as $s) { $s->data['title'] } —— 每次访问 data 都触发一次 json_decode(),N+1 问题立刻爆炸。
- 快照表加
SELECT ... JSON_EXTRACT(snapshot_data, '$.title')直接从数据库取字段,MySQL 5.7+ 支持 JSON 索引,PG 用->>操作符 - 别用 Eloquent 的
with('snapshots')加载全部快照再 PHP 处理;改用Snapshot::where('model_type', 'App\Models\Post')->where('model_id', $id)->orderBy('updated_at', 'desc')->limit(10)->get() - 如果快照量大(比如单模型超 500 条),加个
version_number字段,按版本号查比按时间查更容易分页和缓存
快照不是开箱即用的功能,核心在于控制写入时机、隔离敏感数据、避开 ORM 的隐式行为。最容易被忽略的是:没验证快照表的 JSON 字段是否真的被数据库当 JSON 解析了——插入一条非法 JSON 后,后续所有 JSON_EXTRACT 都静默返回 NULL,查半天才发现是建表时漏写了 JSON 类型。










