Laravel Audit Trail: Building a System That Remembers
इससे जुड़ी जानकारी
Part 1 of 4 — Laravel Architecture Patterns for Production ~10 min read · Compliance · Model logging · Request tracing
A transaction record had been modified.
The amount was different from what the user had submitted. Support escalated it. The user denied changing anything. The developer who last touched the code was on leave. We looked at the database — the record had an updated_at from two days ago and a different value than expected. That was everything we had.
No who. No which fields. No context about what request caused it.
We had a working application. We did not have a system that remembered anything.
That was the incident that made us build this.
Table of Contents
- What you will build
- What an audit trail actually needs to prove
- The request ID: one thread through every log
- Slow queries: while you are here, add this
- The model change logger
- File-based logs and the folder segmentation problem
- Masking sensitive fields
- Where RBAC fits into this picture
- What you now have
- Before you ship — checklist
What you will build
- A request ID generated in middleware that automatically appears in every log line for that request — no manual threading required
- Field-level model diffs that capture what changed from and to, not just that a record was updated
- Append-only log files segmented to stay fast at millions of records
- A Gate hook that logs failed permission checks before they become incidents
What an audit trail actually needs to prove
Before writing code, it is worth being precise about what you are building — because “audit trail” means different things in different contexts, and the requirements determine the architecture.
If you need a queryable, database-backed audit log with a ready-made API, spatie/laravel-activitylog is the standard choice and it is well built. What follows is for when your requirements go further — append-only guarantees, field-level diffs, and audit records that are genuinely hard to tamper with.
In a compliance-heavy environment — fintech, healthcare, any regulated domain — an audit trail needs to answer four questions about any data change:
- Who made the change (user ID, IP address, user agent)
- What changed — not “the record was updated,” but which fields, from what value to what
- When it changed
- Why it is trustworthy — the audit record itself cannot be quietly modified
Most implementations get the first three. The fourth is where they fail, and it is the one that matters most in an actual audit.
Here is the problem with a database audit table: your application code writes to it. That means your application code can also UPDATE it. A table that application code can modify is not an immutable record — it is a mutable history. An auditor who understands this will ask how you prevent tampering, and “we trust our own code” is not a satisfying answer.
This shapes the decision to use file-based logging. But first, there is a more foundational problem: correlation.
The request ID: one thread through every log
A model change does not happen in isolation. It happens during a request — a specific HTTP call from a specific user at a specific moment. Without connecting the model change to that request, you have timestamped facts with no story between them.
The solution is a request ID: a UUID generated at the start of every request and written into every log line that request produces.
Before building the middleware class, add the request and query channels to config/logging.php:
// config/logging.php
'channels' => [
// ... your existing channels ...
'request' => [
'driver' => 'daily',
'path' => storage_path('logs/request.log'),
'level' => 'debug',
'days' => 90, // Retain 90 days — adjust to your compliance requirement
],
'query' => [
'driver' => 'daily',
'path' => storage_path('logs/query.log'),
'level' => 'debug',
'days' => 30,
],
],
Then the middleware:
class RequestLogger
{
public const HEADER_NAME = 'X-Request-Id';
public const REQUEST_ID_ATTRIBUTE = 'request_id'; public function handle(Request $request, Closure $next): Response
{
$requestId = (string) Str::uuid();
// Store in request attributes for internal access within the same request
$request->attributes->set(self::REQUEST_ID_ATTRIBUTE, $requestId);
// Also set as a header — downstream systems and the browser can read it
$request->headers->set(self::HEADER_NAME, $requestId);
// shareContext injects request_id into every Log:: call
// for the rest of this request automatically — no manual threading required
Log::shareContext(['request_id' => $requestId]);
$startedAt = hrtime(true); // Monotonic clock — more accurate than microtime()
$response = $next($request);
$response->headers->set(self::HEADER_NAME, $requestId);
$this->logRequest($request, $response, $requestId, $startedAt);
return $response;
}
private function l...