Laravel中如何详细记录模型属性变更,对比新旧值并存储到审计表?
- 内容介绍
- 文章标签
- 相关推荐
本文共计1175个文字,预计阅读时间需要5分钟。
在Laravel中,`updating` 和 `-saving` 事件通常用于在模型更新前和更新后执行某些操作。以下是一个简单的例子,展示了如何在这些事件中访问模型实例:
常见错误是直接用 $model->getAttributes() 对比,结果发现“旧值”其实是上一次 set 的值,不是 DB 里的真实值——尤其在批量更新或中间件改过属性后,这个坑特别隐蔽。
-
$model->isDirty()可判断哪些字段变了,但不提供旧值,得配合getOriginal('field') - 如果模型是通过
findOrNew()或构造函数新建的,getOriginal()返回空数组,需先exists === false判断再跳过审计 - 对 JSON 字段或序列化字段(如
$casts = ['meta' => 'array']),getOriginal()返回的是原始字符串,要json_decode()后再比对,否则浅对比永远不等
Laravel 用 diffAttributes() 做差异提取容易漏字段
diffAttributes() 看起来方便,但它只比较当前属性和原始属性的键值对,不处理以下情况:
- 数据库默认值字段(比如
status默认'draft'),插入时不设值,getOriginal()没这个 key,diff 就忽略它——但业务上可能需要记录“从无到有”的变化 - 使用
fill()批量赋值后又调save(),若中间有unset($model->xxx),该字段不会出现在getAttributes(),但getOriginal()还有,diff 会误报“删除” - 软删除字段
deleted_at被delete()触发时,getOriginal('deleted_at')是null,但新值是当前时间戳,diff 能捕获;可一旦模型被 restore,deleted_at又变回null,这时 diff 会显示“从时间戳 → null”,但业务语义是“恢复”,不是“清空”
建议自己写一个最小化 diff 工具函数,显式声明要审计的字段白名单,逐个检查 isset($original[$key]) 和 array_key_exists($key, $original),避免依赖框架自动推导。
审计日志存入数据库时,json 字段 vs text 字段的实际影响
用 MySQL 存差异日志,别图省事全扔 text。虽然 json 类型支持索引和查询函数(如 JSON_CONTAINS),但 Laravel 8+ 默认把数组 cast 为 json 字段时,会强制格式化(加空格、排序 key),导致两次相同变更生成的 JSON 字符串不一致,无法用 = 直接查重复日志。
- 如果要用
json字段存changes,务必在入库前用json_encode($diff, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)标准化,且不加JSON_PRETTY_PRINT - PostgreSQL 的
jsonb没这个问题,但 Laravel 的whereJsonContains()在 PG 上实际走的是表达式函数,不能命中索引,真要查得快,还是拆成单独字段(如changed_field,old_value,new_value) - 审计表主键别用自增 ID,用
ULID或UUID,避免高并发下日志顺序和真实发生顺序错乱(尤其配合队列异步写日志时)
监听 saving 事件写审计日志,为什么有时没生效
最常踩的坑是:在 AppServiceProvider::boot() 里用 User::saving(...) 绑定事件,但模型类还没被 autoload,Laravel 不报错也不触发——因为事件绑定发生在类定义之前。
- 正确做法是在模型内部用
static::saving(),或在EventServiceProvider的$listen数组里配eloquent.saving: App\Listeners\LogModelChange - 如果审计逻辑里调用了其他 Eloquent 操作(比如查用户姓名),要确保不在事务里嵌套事务,否则可能死锁;用
DB::transactionLevel() === 0判断是否已在事务中 - 别在事件里 throw Exception,否则整个保存失败;审计失败应静默记录到
storage/logs/audit-fail.log,不影响主流程
真正麻烦的从来不是怎么记,而是怎么保证“每次变更都记、只记一次、不拖慢主请求”。异步队列 + 延迟写入 + 失败重试才是生产环境的常态,同步写只是开发期验证用的临时方案。
本文共计1175个文字,预计阅读时间需要5分钟。
在Laravel中,`updating` 和 `-saving` 事件通常用于在模型更新前和更新后执行某些操作。以下是一个简单的例子,展示了如何在这些事件中访问模型实例:
常见错误是直接用 $model->getAttributes() 对比,结果发现“旧值”其实是上一次 set 的值,不是 DB 里的真实值——尤其在批量更新或中间件改过属性后,这个坑特别隐蔽。
-
$model->isDirty()可判断哪些字段变了,但不提供旧值,得配合getOriginal('field') - 如果模型是通过
findOrNew()或构造函数新建的,getOriginal()返回空数组,需先exists === false判断再跳过审计 - 对 JSON 字段或序列化字段(如
$casts = ['meta' => 'array']),getOriginal()返回的是原始字符串,要json_decode()后再比对,否则浅对比永远不等
Laravel 用 diffAttributes() 做差异提取容易漏字段
diffAttributes() 看起来方便,但它只比较当前属性和原始属性的键值对,不处理以下情况:
- 数据库默认值字段(比如
status默认'draft'),插入时不设值,getOriginal()没这个 key,diff 就忽略它——但业务上可能需要记录“从无到有”的变化 - 使用
fill()批量赋值后又调save(),若中间有unset($model->xxx),该字段不会出现在getAttributes(),但getOriginal()还有,diff 会误报“删除” - 软删除字段
deleted_at被delete()触发时,getOriginal('deleted_at')是null,但新值是当前时间戳,diff 能捕获;可一旦模型被 restore,deleted_at又变回null,这时 diff 会显示“从时间戳 → null”,但业务语义是“恢复”,不是“清空”
建议自己写一个最小化 diff 工具函数,显式声明要审计的字段白名单,逐个检查 isset($original[$key]) 和 array_key_exists($key, $original),避免依赖框架自动推导。
审计日志存入数据库时,json 字段 vs text 字段的实际影响
用 MySQL 存差异日志,别图省事全扔 text。虽然 json 类型支持索引和查询函数(如 JSON_CONTAINS),但 Laravel 8+ 默认把数组 cast 为 json 字段时,会强制格式化(加空格、排序 key),导致两次相同变更生成的 JSON 字符串不一致,无法用 = 直接查重复日志。
- 如果要用
json字段存changes,务必在入库前用json_encode($diff, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)标准化,且不加JSON_PRETTY_PRINT - PostgreSQL 的
jsonb没这个问题,但 Laravel 的whereJsonContains()在 PG 上实际走的是表达式函数,不能命中索引,真要查得快,还是拆成单独字段(如changed_field,old_value,new_value) - 审计表主键别用自增 ID,用
ULID或UUID,避免高并发下日志顺序和真实发生顺序错乱(尤其配合队列异步写日志时)
监听 saving 事件写审计日志,为什么有时没生效
最常踩的坑是:在 AppServiceProvider::boot() 里用 User::saving(...) 绑定事件,但模型类还没被 autoload,Laravel 不报错也不触发——因为事件绑定发生在类定义之前。
- 正确做法是在模型内部用
static::saving(),或在EventServiceProvider的$listen数组里配eloquent.saving: App\Listeners\LogModelChange - 如果审计逻辑里调用了其他 Eloquent 操作(比如查用户姓名),要确保不在事务里嵌套事务,否则可能死锁;用
DB::transactionLevel() === 0判断是否已在事务中 - 别在事件里 throw Exception,否则整个保存失败;审计失败应静默记录到
storage/logs/audit-fail.log,不影响主流程
真正麻烦的从来不是怎么记,而是怎么保证“每次变更都记、只记一次、不拖慢主请求”。异步队列 + 延迟写入 + 失败重试才是生产环境的常态,同步写只是开发期验证用的临时方案。

