直接在Eloquent的$casts或getFooAttribute中加日志无效,因accessor/mutator仅在模型属性读写时触发,无法捕获批量更新、原生SQL等绕过模型的变更,审计需覆盖所有数据修改场景。

为什么直接在 Eloquent 的 $casts 或 getFooAttribute 里加日志会出问题
因为 Eloquent 的访问器(accessor)和修改器(mutator)只在模型属性读写时触发,不覆盖底层 SQL 操作;而审计跟踪需要捕获「谁、什么时候、对哪条记录、做了什么变更」,包括批量更新、update()、save()、甚至原生 DB::table()->where()->update() —— 这些都不会走模型的 accessor/mutator。
所以硬塞日志到 setBalanceAttribute 里,只能捕获显式赋值行为,漏掉绝大多数真实业务变更场景。
- 批量更新(如
User::where('status', 'pending')->update(['status' => 'active']))完全绕过模型生命周期 -
fill()+save()时若字段未在$fillable中,也不会触发 mutator - 事务中多次修改同一模型实例,accessor 可能被重复调用,但审计只应记录最终持久化状态
用 static::updating() 和 static::updated() 钩子做最小侵入审计
这两个静态事件在模型真正写入数据库前后触发,且保证每次 SQL UPDATE 都经过(只要走的是 Eloquent ORM),是记账审计最可靠的位置。关键是要在 updated 里比对变更前后的原始属性值,而不是靠访问器临时拼接。
示例:对 Account 模型做余额变动审计
class Account extends Model
{
protected static function booted()
{
static::updating(function ($model) {
// 记录变更前快照(仅当 balance 字段实际被改)
if ($model->isDirty('balance')) {
$model->audit_before = [
'balance' => $model->getOriginal('balance'),
'updated_at' => $model->getOriginal('updated_at'),
];
}
});
<pre class="brush:php;toolbar:false;"> static::updated(function ($model) {
if (isset($model->audit_before) && $model->isDirty('balance')) {
$before = $model->audit_before['balance'];
$after = $model->balance;
$delta = $after - $before;
\App\Models\AccountAudit::create([
'account_id' => $model->id,
'delta' => $delta,
'before_balance' => $before,
'after_balance' => $after,
'operator_id' => auth()->id() ?? null,
'ip' => request()?->ip(),
'updated_at' => now(),
]);
}
});
}
}
- 必须用
getOriginal('balance'),不是$model->balance—— 后者已是新值 -
isDirty('balance')要放在两个钩子里都判断,避免updating未设audit_before却在updated里误执行 - 不要在
updating里直接创建审计记录:万一事务回滚,审计就成脏数据
如何处理 DB::table() 或原生 SQL 导致的审计盲区
如果业务中存在绕过 Eloquent 直接操作表的情况(比如定时任务跑大额对账、导入脚本),审计必然断层。这时候不能强行改代码走模型,而是得用数据库层兜底。
- MySQL 可建
AFTER UPDATE触发器,监听account表的balance字段变化,写入account_audit表 - PostgreSQL 推荐用
pg_notify+ LISTEN,在触发器里发消息,由 Laravel 的队列消费者接收并落库(避免触发器内阻塞主事务) - 更轻量的做法:所有非 Eloquent 的写操作,强制要求调用一个封装函数,如
Account::rawUpdateBalance($id, $delta),内部统一补审计日志
没有银弹。触发器可靠但难调试;封装函数可控但依赖团队约定;混合方案(Eloquent 钩子 + 关键脚本白名单封装)最常用。
AccountingAttribute 不该是个 Trait,而该是独立的审计服务类
把「记账逻辑」和「模型定义」耦合进 Trait(比如叫 HasAccountingAttribute),看似复用,实则埋坑:一旦某天要对同一张表启用多套审计策略(如财务审计 vs 运营审计),Trait 就无法隔离行为。
推荐拆成服务类:
class AccountBalanceAuditor
{
public function recordChange(Account $account, $operatorId = null)
{
if (! $account->wasChanged('balance')) return;
<pre class="brush:php;toolbar:false;"> AccountAudit::create([
'account_id' => $account->id,
'delta' => $account->balance - $account->getOriginal('balance'),
'operator_id' => $operatorId ?? auth()->id(),
'ip' => request()?->ip(),
]);
}
}
// 在控制器或 service 中显式调用 $account->balance += 100; $account->save(); app(AccountBalanceAuditor::class)->recordChange($account);
- 职责清晰:模型只管数据,审计只管留痕
- 可单元测试:直接 new 审计类,mock $account 实例,验证 create 是否被调用
- 支持条件启用:比如只在
App::environment('production')下才记录,开关一目了然
真正难的从来不是“怎么记”,而是“哪些变更必须记、谁有权查、保留多久、是否要同步给风控系统”——这些不在代码里,而在业务规则文档和 DBA 的归档策略里。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











