Audit Logs: Who Changed What, and Whether You Can Prove It

An activity trail answers the questions support tickets and disputes actually ask: who changed this record, when, and what it looked like before. How to build audit logging that holds up.

Audit logs: who changed what, and whether you can prove it

Error logs tell you the application broke. Audit logs answer a different question: who changed this record, when, and what did it look like before. The question arrives first as a support ticket ("the company address changed and nobody knows who did it"), then as a customer disputing a charge, and eventually as a compliance review asking for a history of administrative actions. If audit logging is built after the first time someone asks, the answer is "we don't have that history", and there is no way to backfill it.

Record the five things

Every entry needs to stand on its own, months later, with nobody around to explain it:

  • Actor: who did it. A user ID for humans, a system identifier for automated changes, and a flag when the change happened through admin impersonation, because "the customer changed it" and "a support agent changed it while impersonating the customer" are different facts.
  • Action: the verb. updated, deleted, role.granted. Stable, greppable names.
  • Target: what it was done to. Model class and ID at minimum.
  • Change: the before and after of the fields that actually changed, not the whole object.
  • Context: when (stored in UTC), plus IP and user agent. Keep the timestamp UTC; an audit trail rendered in the server's local zone is a puzzle you only want to solve once.
{
  "actor_id": 41,
  "action": "updated",
  "target": "Invoice:8821",
  "changes": { "status": { "from": "draft", "to": "sent" } },
  "ip": "203.0.113.7",
  "created_at": "2026-10-02T09:12:44+00:00"
}

Don't log everything

Two things make an audit log a liability instead of an asset.

Secrets must never land in it. Passwords, API tokens, payment card data: if the audit table captures full request payloads, the trail of who changed what becomes a copy of everything you worked to keep out of your database. Log the action and the field names; omit the sensitive values or store them hashed. The same test that asserts the change was recorded should assert what is not in the record.

Personal data needs a retention period. Under GDPR, an audit log about a person is personal data, and "we keep it forever" is not an answer. Pick a retention window that matches what the log is actually for (disputes and compliance usually need months, not decades), and enforce deletion when it expires, including for deleted users. Store the user ID, not a full profile copy.

Build it thin

You do not need an event-sourcing framework for this. In Laravel, hooking model events (or an established package that does) and writing rows to an audit_logs table covers most needs: one table with columns for actor, action, target, and a JSON column for the diff. Write through the queue if the volume demands it, but a simple insert is fine to start.

The important property is append-only. Nobody edits audit entries, including admins. If a record can be corrected or quietly removed, the log proves nothing in a dispute, and you have built a notes field with extra steps. Keep the table out of any admin CRUD, and use database permissions to enforce it where the compliance bar is high.

Make it discoverable

A history tab on each record in the admin panel, showing the diff per field, turns the log from an insurance policy into a daily tool. Once support can answer "who changed this" in ten seconds, they stop asking developers, and the log starts earning its storage. Hidden audit logs rot: nobody notices when they break, and the gap is discovered exactly when the log is needed.

Test it like code

Feature tests should assert the audit row along with the behavior: the actor is right, the diff contains the changed fields, the sensitive fields are absent. Then change a model and watch the test fail if the logging broke. An audit log is the rare feature whose failures are invisible by design, which is exactly why it needs tests.