Audit Trails: Who Did What, When, and Prove It
· Jerwin Arnado · 7 min read ·
The first three parts of this series decide what’s allowed to happen. This part records what did happen — because the day you need an audit trail, it’s too late to start one. Three different people will come asking, and they want the same table:
- Support/debugging: “the patient’s balance is wrong — who changed this invoice?”
- Compliance: “show us every access to this medical record in March.”
- Security: “this account was phished Tuesday — what did the attacker touch?”
In a multi-tenant clinic system, that third question isn’t hypothetical. Here’s the trail that can answer all three.
What a useful audit event records
The unit of an audit trail is one event answering five questions:
| Question | Field | Example |
|---|---|---|
| Who | causer (user id, not name — names change) | user:412 |
| Did what | action verb | updated, viewed, denied |
| To what | subject (type + id) | invoice:9018 |
| What changed | before → after diff, changed keys only | status: draft → sent |
| In what context | timestamp, IP, request id, tenant | 2026-08-01 09:14 +0800, t:17 |
The diff is what separates an audit trail from an access log. “Maria updated invoice 9018”
starts an argument; “Maria changed total from ₱4,500 to ₱450” ends one.
Model changes: spatie/laravel-activitylog
The same shop behind the permission package from part one ships the standard tool here. One trait per model worth auditing:
class Invoice extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'total', 'due_at', 'paid_at']) // allowlist, not everything
->logOnlyDirty() // changed keys only
->dontSubmitEmptyLogs();
}
}
Every create/update/delete now writes an activity row with the causer (the authenticated user) and the old/new values of the changed keys — the who/what/diff columns of the table above, for free. Reading it back is a query, not archaeology:
Activity::forSubject($invoice)->latest()->get(); // this invoice's whole history
Activity::causedBy($user)->whereDate('created_at', $tuesday)->get(); // the phishing question
Note the allowlist. logAll() is a trap: it happily records password hashes,
remember-tokens, and API keys into a table with far looser access rules than the columns
themselves. Name the fields that matter; skip the secrets and the noise (updated_at
tells nobody anything).
Log the denials, not just the changes
Model events only capture what happened. For security work, what was refused matters just as much — a burst of 403s is an attacker (or a broken policy) announcing itself, and part one’s caveat already asked for this. One hook covers every policy in the app:
// AppServiceProvider::boot() — observe every gate decision that denies
Gate::after(function (User $user, string $ability, ?bool $result, array $arguments) {
if ($result === false) {
activity('authz')
->causedBy($user)
->withProperties([
'ability' => $ability,
'subject' => $arguments[0] ?? null ? class_basename($arguments[0]).':'.$arguments[0]->getKey() : null,
'ip' => request()->ip(),
])
->log('denied');
// Return nothing — after() observes; returning a value would override the decision.
}
});
Now “why can’t Maria edit this?” is a lookup instead of a debugging session, and your
monitoring can alert on denial spikes per user. Pair it with the framework’s auth events —
Login, Logout, Failed — via listeners, and the trail covers the
authentication layer too: failed-login clusters
before a successful login is the phishing timeline, reconstructed in one query.
Append-only, or it isn’t an audit log
The property that makes an audit trail evidence is that nobody — including admins, including the app — can quietly rewrite it. A log the attacker (or the disgruntled admin) can edit is a diary, not a trail. Concretely:
- No update or delete path in the app. No model method, no admin UI button, no
“fix the log entry” endpoint. The
Activitymodel is write-and-read only. - Enforce it below the app if you can. A dedicated DB user for the app whose grants on
the
activity_logtable areINSERT, SELECTonly — then even an application-level bug can’t tamper. (RDS/Postgres make this a five-minute job.) - Retention is a policy, not a button. Pruning two-year-old rows on a schedule
(Laravel’s
model:prune) is fine — that’s a documented policy applied uniformly. Deleting specific rows is exactly the capability the log exists to preclude. - The trail is tenant-scoped too. Activity rows carry the tenant like every other model from part three — clinic A must never see clinic B’s history, and the absence of scoping here is itself a leak.
Caveats and best practices
- Decide read-access up front. The audit log concentrates sensitive history in one table; “who can read the trail” is itself an authorization question from part one. Physicians shouldn’t browse colleagues’ activity; support sees their tenant only.
- Keep it off the hot path if volume demands. Activity writes are one insert — usually fine inline. If a bulk import hammers it, queue the writes; an audit row that lands a second late is still true.
- Index for the questions you’ll ask.
(subject_type, subject_id)and(causer_id, created_at)cover the support and security questions. An unqueryable audit log fails silently — you find out the day you need it under pressure. - An audit trail is not error tracking. Errors answer “what broke”; the trail answers “who did what.” Different retention, different access rules, different tables. Resist merging them because both are “logs.”
- Record views only where the stakes justify it. Logging every read of everything drowns the trail. Medical records: yes, log the view — that’s a compliance requirement. Invoice list pages: no.
Conclusion
Event → who, did what, to what, diff, context — causer + subject + changed keys
Models → LogsActivity with an allowlist; logOnlyDirty; never logAll
Denials→ Gate::after logs every 403 with ability + subject; auth events for logins
Trust → append-only: no update path, INSERT/SELECT grants, retention ≠ deletion
Authorization without an audit trail is a lock with no camera: it keeps honest people out and tells you nothing when someone gets in. Log the changes with their diffs, log the denials, make the table append-only, and index it for the three questions that are coming. Final part of the series: what happens when your permission rules stop being rules and become a graph.