Building an Audit Trail You Can Actually Trust

An updated_at column tells you something changed. It doesn't tell you who changed it, what it was before, or whether a person or a script did it. Here's what a proper audit trail looks like.

Building an Audit Trail You Can Actually Trust

Someone asks "who changed this record, and when, and what did it say before?" and you go and look. All you've got is updated_at. It tells you the row changed on Tuesday at 14:32. It doesn't tell you who did it, what the value was before, or whether a person clicked a button or a script ran overnight. At that point you don't have an audit trail. You have a rumour.

This comes up more than people expect, usually at the worst moment: a customer disputes a charge, a regulator asks for evidence, or two staff members each swear they didn't touch a record that clearly changed. If your answer is "the system doesn't really keep that", you've got a problem that's expensive to fix after the fact and cheap to fix before.

Why updated_at doesn't count

A single timestamp column has three fatal flaws as evidence:

  • It gets overwritten. Every edit replaces the last one. There's no history, just the most recent state and a time.
  • It has no actor. A timestamp tells you when, never who or what triggered it.
  • It doesn't distinguish causes. A user editing a form, a scheduled job correcting a stock level, and a webhook from a payment provider all touch updated_at the same way. You can't tell them apart afterwards.

None of this is a criticism of updated_at itself. It's a useful column for cache invalidation and sorting by recency. It was never designed to be evidence, and treating it as such is where the trouble starts.

What a real audit trail records

A trustworthy audit entry needs to answer five questions on its own, without anyone having to cross-reference other tables or guess:

  • Who (or what) made the change. A user id if a person did it, a clearly labelled system actor (job name, integration name, cron task) if something automated did it. Never leave this blank or default it to "system" without saying which system.
  • When it happened. A timestamp stored at the moment of the change, in a fixed timezone, not derived later.
  • What it was before and what it became. The previous value and the new value, not just the new one. If you only store the new state, you can't answer "what did this used to say", which is usually the actual question being asked.
  • Where it came from, when that matters. IP address and device or user agent are worth capturing for sensitive actions like changing bank details, deleting a record, or overriding a price. You don't need this on every field of every table. You do need it wherever a dispute is plausible.
  • Why, if there's a reason attached. Some changes come with a stated reason (a refund note, a manual override justification). If your workflow captures that reason, store it alongside the change rather than in a separate free-text field nobody links back.

System-generated changes need the same rigour

The most common gap I see isn't missing user activity, it's missing everything else. A record gets updated by a nightly reconciliation job, an inbound webhook, an import script, or a support agent running a console command directly against the database. None of these go through the normal "edited via the UI" path, so none of them get logged if your logging lives only in the form-handling code.

The fix is to put the audit logic at the layer everything passes through, not the layer only humans use. If you're on Laravel, that usually means hooking into model events (saving, updating, deleting) rather than only logging inside controllers, so a change made by a queued job is captured exactly the same way as one made by a person in a browser. The actor field just needs to say "system: nightly-stock-sync" instead of a user's name, and the change is just as visible.

Immutability is the whole point

An audit trail that can be edited or deleted isn't an audit trail, it's just another table someone can tidy up under pressure. The entries need to be append-only: once written, never updated, never deleted, and ideally not deletable even by an admin account through the normal application. If a record needs correcting, you add a new entry that says so, you don't erase the old one.

Practically, that means the audit table shouldn't share update or delete permissions with anything else, and it shouldn't be something a support script can quietly prune when the database gets big. If storage becomes a genuine issue, archive old entries somewhere colder rather than deleting them.

Where this actually matters

Not every table needs this treatment. A blog post's edit history is nice to have. A customer's payment method, a staff member's pay rate, a policy document that people are meant to have acknowledged, or anything feeding into a dispute or a regulatory question, that's where an audit trail stops being a nice-to-have and starts being the thing that saves you in a difficult conversation. On a policy management system I built, the audit trail records not just who changed a policy but who acknowledged reading it and when, because that record is often the entire point of the system.

A short checklist

  • Does every sensitive change record an actor, not just a timestamp?
  • Do you store the previous value, not just the new one?
  • Are system-generated changes labelled clearly, with which system did it?
  • Can anyone, including an admin, edit or delete an audit entry?
  • Have you tested what happens when a change comes from a job or webhook, not just a form?

If you can't answer all five with confidence, it's worth an afternoon working through the model events and the audit table structure before you need the answer for someone else. It's a much smaller job to build properly now than to reconstruct after the fact.