需通过DB::listen拦截SQL并用静态变量trackLevel追踪嵌套层级,结合debug_backtrace获取调用栈,为预加载注入标识,最终输出带缩进与caller信息的结构化审计日志。

在Laravel项目中记录嵌套查询的完整执行链路(如主查询→关联预加载→子查询),需绕过Eloquent默认日志截断机制,捕获原始SQL、绑定参数及调用栈上下文。直接使用DB::listen只能拿到单条语句,无法体现嵌套关系,必须从Query Builder底层拦截并维护执行层级状态。
启用全局查询监听并注入层级追踪
在AppServiceProvider的boot方法中注册监听器,用静态变量trackLevel模拟调用栈深度:
在app/Providers/AppServiceProvider.php中添加:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
static $trackLevel = 0;
if (str_starts_with($query->sql, 'select') && preg_match('/from.*\(/i', $query->sql)) {
$trackLevel++;
} elseif (str_starts_with($query->sql, ') as')) {
$trackLevel = max(0, $trackLevel - 1);
}
$indent = str_repeat('│ ', $trackLevel);
Log::info("{$indent}SQL: {$query->sql}", ['bindings' => $query->bindings, 'time' => $query->time]);
});
为Eloquent关联预加载打上嵌套标记
Eager loading的with()调用不触发DB::listen,需在Relation类中插入钩子。以HasMany为例,在模型关联方法中手动记录:
在User模型中定义posts关系时修改:
public function posts()
{
$relation = $this->hasMany(Post::class);
return $relation->tap(function ($query) {
// 注入自定义属性标识预加载上下文
$query->setBindings(array_merge($query->getBindings(), ['__eager_load__' => $this->id]));
});
}
这一步让预加载SQL在DB::listen中能通过bindings识别出来源,避免与普通查询混淆。
捕获完整嵌套调用栈(关键步骤)
仅靠SQL文本无法区分同一SQL在不同嵌套层的执行,必须绑定PHP调用位置:
第一步:在DB::listen回调内获取当前执行点
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
foreach ($backtrace as $frame) {
if (isset($frame['class']) && $frame['class'] === 'Illuminate\Database\Eloquent\Builder' && $frame['function'] === 'get') {
$caller = $frame['file'] . ':' . $frame['line'];
break;
}
}
第二步:将caller信息注入日志上下文
Log::info("SQL: {$query->sql}", [
'bindings' => $query->bindings,
'time' => $query->time,
'caller' => $caller ?? 'unknown',
'level' => $trackLevel
]);
第三步:在日志处理器中按level缩进合并输出,确保父子查询视觉可辨。这要求日志驱动支持结构化字段(如Monolog的LineFormatter需开启include_stacktraces=false防止干扰)。
【caller字段必须存在,否则无法定位嵌套源头】
生成审计日志文件(非数据库存储)
方法一:写入独立日志通道
在config/logging.php中新增channel:
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit-query.log'),
'level' => 'debug',
'days' => 14,
],
方法二:动态切换日志实例
Log::channel('audit')->info(...); 替换原Log::info调用。
方法三:使用StreamHandler直写文件(绕过Laravel日志管道)
$handler = new \Monolog\Handler\StreamHandler(storage_path('logs/nested-audit.log'), Logger::DEBUG);
$logger = new \Monolog\Logger('nested-audit');
$logger->pushHandler($handler);
$logger->info(...);


















