Introduction: updated_at Is Not an Audit Trail#
Every SaaS product eventually gets the same request from a customer's compliance or security team, usually right before a contract renewal or a SOC 2 review: "Can you show us exactly who changed this record, what it looked like before, and when?" The team scrambles, and what they usually find is that the product has been quietly answering a much weaker question all along — updated_at tells you when a row last changed, not who changed it, not what it looked like before, and nothing at all about the four changes that happened in between the version that exists now and the version that existed a month ago. Laravel's soft deletes are the same story wearing a different hat: a deleted_at timestamp tells a customer a record is gone, not who deleted it, why, or whether it was restored and deleted again since.
The gap isn't a missing feature so much as a category error. Row history and audit logging solve different problems that look similar from a schema diagram. Row history — a revisions table, an event-sourced projection — is about being able to reconstruct what a record looked like at a point in time. Audit logging is about being able to answer, with evidence a third party would trust, who did what, to whom, and when, in a way the person who did it can't quietly go back and edit. The second one is the actual compliance requirement, and it has a property that most application logging doesn't: the log itself has to be at least as trustworthy as the data it's describing, which means the same admin who can edit an invoice can't be allowed to edit the record that says they edited the invoice.
I've had to build this discipline into products where "who did this, and can you prove it" isn't a nice-to-have: SafetySpace, the AI-driven safety management platform I run as CTO, where a compliance-regulated customer's audit request isn't hypothetical — a safety officer's SWMS approval history, a field worker's incident report edits, and every change to who held safety-officer access all need to survive a customer's own external audit; SwapPad, the bulk SIM activation platform inside CelleUp's dealer back office, where a dealer's wallet balance moving by a few hundred dollars needs a trail back to the specific batch, carrier response, and admin override that caused it, because wallet accounting disputes are financial disputes; and FileManager, the Laravel/Livewire/S3 document system, where a business customer's legal or HR team needs to know exactly who viewed or downloaded a sensitive document, not just who currently has permission to.
This is a breakdown of how to build audit logging in Laravel that actually holds up when a customer's auditor asks for it: an append-only store that the application itself can't quietly rewrite, structured before/after state instead of a vague description string, actor and tenant context captured automatically instead of remembered per-feature, and the retention and redaction tradeoffs that show up the moment "keep everything forever" collides with a data-deletion request.
Architecture: What Makes a Log Actually Auditable#
Writing rows to an activity_log table is the easy 80%. The other 20% — the part that makes the log something a customer's auditor or your own legal team can actually rely on — is making it structurally resistant to being wrong, incomplete, or quietly edited after the fact.
1. Audit records are append-only, structurally, not by convention#
A AuditLog Eloquent model with no special protections is only an audit trail until someone with database access — a support engineer running a one-off script, a compromised admin account, a bug in unrelated cleanup code — updates or deletes a row in it. "We don't write code that edits audit logs" is a policy. It is not a guarantee, and a compliance auditor's whole job is not taking policies at face value.
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->foreignId('actor_id')->nullable()->constrained('users');
$table->string('actor_type')->default('user'); // user, system, api_token
$table->string('event'); // e.g. "invoice.deleted", "role.assignment.revoked"
$table->string('auditable_type');
$table->unsignedBigInteger('auditable_id');
$table->json('before')->nullable();
$table->json('after')->nullable();
$table->string('request_id')->nullable(); // ties a log row back to the request that caused it
$table->string('ip_address', 45)->nullable();
$table->string('checksum'); // covered below
$table->timestamp('created_at'); // no updated_at — nothing updates this table
});The absence of updated_at is deliberate — this table has no update path. In Postgres, the actual guarantee comes from revoking UPDATE and DELETE privileges on the table from the application's own database role, so the enforcement lives below the ORM, where a bug or a rogue script can't route around it:
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
GRANT INSERT, SELECT ON audit_logs TO app_user;An Eloquent model can still be given an UpdateAuditLogException-throwing guard as a second line of defense, but the database grant is the one that actually holds when the application layer is the thing that's compromised or buggy.
class AuditLog extends Model
{
const UPDATED_AT = null;
public function update(array $attributes = [], array $options = [])
{
throw new \LogicException('Audit log entries are append-only and cannot be updated.');
}
protected static function booted(): void
{
static::deleting(fn () => throw new \LogicException('Audit log entries cannot be deleted.'));
}
}2. Chain each entry to the one before it, so tampering is detectable, not just discouraged#
Append-only stops casual edits. It doesn't stop someone with direct database access from deleting a row and re-inserting a doctored one with a matching ID — Postgres has no built-in notion of "this row's history is provably intact." A hash chain closes that gap cheaply: each row's checksum is a hash of its own contents plus the previous row's checksum, so altering or removing any row breaks every checksum after it, and that breakage is trivially detectable by recomputing the chain.
class RecordAuditEvent
{
public function handle(string $tenantId, string $event, Model $model, ?array $before, ?array $after): AuditLog
{
return DB::transaction(function () use ($tenantId, $event, $model, $before, $after) {
$previous = AuditLog::where('tenant_id', $tenantId)
->orderByDesc('id')
->lockForUpdate() // serialize writes per tenant so the chain can't fork under concurrency
->first();
$payload = [
'tenant_id' => $tenantId,
'actor_id' => Auth::id(),
'event' => $event,
'auditable_type' => $model::class,
'auditable_id' => $model->getKey(),
'before' => $before,
'after' => $after,
'request_id' => request()->header('X-Request-Id'),
'ip_address' => request()->ip(),
'created_at' => now(),
];
$payload['checksum'] = hash('sha256', ($previous->checksum ?? 'genesis') . json_encode($payload));
return AuditLog::insert($payload) ? AuditLog::latest()->first() : throw new \RuntimeException('Audit write failed');
});
}
}Verifying the chain later — during a customer's own audit, or a scheduled integrity check — is a linear walk that recomputes each checksum and confirms it still matches:
class VerifyAuditChainIntegrity extends Command
{
protected $signature = 'audit:verify {tenant}';
public function handle(): int
{
$previous = null;
foreach (AuditLog::where('tenant_id', $this->argument('tenant'))->orderBy('id')->cursor() as $log) {
$expected = hash('sha256', ($previous->checksum ?? 'genesis') . $log->toChecksumPayload());
if (! hash_equals($expected, $log->checksum)) {
$this->error("Chain broken at audit log #{$log->id}");
return self::FAILURE;
}
$previous = $log;
}
$this->info('Audit chain intact.');
return self::SUCCESS;
}
}This is the same trust model as idempotency keys in one sense — a structural guarantee, backed by a database constraint or a cryptographic check, instead of an assumption that the application code always behaves. The difference is what's being protected: idempotency protects against a request happening twice; a hash chain protects against history being rewritten after the fact.
3. Capture actor and tenant context automatically, at one layer — not per-feature#
The single most common way audit logging degrades in practice isn't a missing table, it's inconsistency: one developer remembers to log invoice deletions, another forgets to log role changes, a third logs role changes but not the tenant they happened in, and six months later nobody can say with confidence which actions are actually covered. The fix is the same one that works for tenant isolation and role-based access control: one enforcement point, not a convention every feature has to remember.
trait Auditable
{
protected static function bootAuditable(): void
{
static::updated(function (Model $model) {
if ($model->wasChanged(array_diff($model->getDirty(), $model->getHidden()))) {
app(RecordAuditEvent::class)->handle(
app(TenantContext::class)->id(),
strtolower(class_basename($model)) . '.updated',
$model,
Arr::only($model->getOriginal(), array_keys($model->getChanges())),
Arr::only($model->getAttributes(), array_keys($model->getChanges())),
);
}
});
static::deleted(fn (Model $model) => app(RecordAuditEvent::class)->handle(
app(TenantContext::class)->id(),
strtolower(class_basename($model)) . '.deleted',
$model,
$model->getOriginal(),
null,
));
}
}
class Invoice extends Model
{
use Auditable, SoftDeletes;
}A model observer catches routine field-level changes for free. It doesn't catch business-meaningful events that aren't a simple attribute diff — a role revoked from a user, a subscription downgraded, a safety officer approving a SWMS document — those get logged explicitly, from the same RecordAuditEvent service, at the point the domain event actually happens:
class LogRoleRevocation
{
public function handle(RoleRevoked $event): void
{
app(RecordAuditEvent::class)->handle(
$event->tenantId,
'role.assignment.revoked',
$event->userAffected,
['role' => $event->roleName, 'held_since' => $event->assignedAt],
null,
);
}
}This is the deliberate expansion of the audit trail this series already flagged as a gap in RBAC architecture — logging that access was revoked is half the story, and the model-level Auditable trait plus explicit domain-event logging together are what closes it, instead of leaving "who changed access" as a manual grep through support tickets.
4. Read replicas or a separate audit datastore, so a compliance query never competes with production traffic#
A customer's compliance team pulling twelve months of audit history for an external review is a different workload than the transactional queries the same tables serve all day — a wide date-range scan with no natural index locality can degrade production query performance right when the rest of the app needs it least. Routing audit reads to a dedicated connection (a read replica, or the audit table living in its own logically-separated schema) keeps a compliance export from becoming a production incident.
class AuditLog extends Model
{
protected $connection = 'audit'; // separate connection, can point at a replica
}Step-by-Step: Making the Trail Actually Query-able#
- Index for the query an auditor actually asks, not just the primary key. "Show me everything that happened to this record" (
auditable_type,auditable_id) and "show me everything this person did" (actor_id,created_at) are the two access patterns that matter in practice — index for those explicitly rather than relying on a full scan with aWHERE tenant_id = ?filter.
Schema::table('audit_logs', function (Blueprint $table) {
$table->index(['tenant_id', 'auditable_type', 'auditable_id']);
$table->index(['tenant_id', 'actor_id', 'created_at']);
});- Store
before/afteras structured JSON, never a rendered sentence."Invoice #4821 total changed from $200 to $350 by admin@acme.com"reads nicely in a UI and is useless the moment someone needs to diff two states programmatically, redact a field, or answer "which invoices had their total changed by more than 50% last quarter" as a query instead of a manual read-through.
- Log the denial, not just the action. A customer's security team asking "did anyone attempt to access data they weren't authorized for" is a real audit question, and a system that only logs successful actions has no answer to it:
class LogAuthorizationFailure
{
public function handle(Unauthorized $event): void
{
app(RecordAuditEvent::class)->handle(
$event->tenantId,
'authorization.denied',
$event->subject,
null,
['attempted_permission' => $event->permission, 'actor_id' => $event->userId],
);
}
}- Surface the trail in-product, scoped by the same tenant and permission boundary as everything else. An audit log a customer can only get by emailing support is a log that effectively doesn't exist for day-to-day trust-building — a scoped, paginated
GET /audit-logs?auditable_type=Invoice&auditable_id=4821endpoint, gated by its ownaudit-logs.viewpermission (not bundled into a general admin role), turns "can you show us" into a feature instead of a ticket.
- Reconcile retention against deletion requests explicitly — don't let them collide silently. A GDPR or CCPA erasure request and "keep an immutable audit trail forever" are in direct tension, and pretending they aren't is how a product ends up either non-compliant with a deletion request or unable to produce the audit history a different regulation requires. The resolution is field-level redaction that preserves the event while removing the personal data inside it, rather than deleting or keeping the row wholesale:
class RedactPersonalDataFromAuditLogs
{
public function handle(User $subject): void
{
AuditLog::where('actor_id', $subject->id)
->orWhere(function ($q) use ($subject) {
$q->where('auditable_type', User::class)->where('auditable_id', $subject->id);
})
->get()
->each(fn (AuditLog $log) => DB::table('audit_logs')->where('id', $log->id)->update([
'before' => $log->redactPersonalFields($log->before),
'after' => $log->redactPersonalFields($log->after),
]));
}
}Note this is the one deliberate exception to "append-only, no updates" — and it's why the redaction path writes through a raw query builder call with its own explicit audit event logged for the redaction itself, rather than quietly bypassing the AuditLog model's own update guard. A compliance-driven redaction needs to be as visible in the trail as everything else it touches.
- Set a retention policy per event class, not one blanket duration. SafetySpace keeps safety-incident and access-control audit events for the regulatory minimum a safety-compliance customer's industry requires, while lower-stakes events (a display-preference change) can age out sooner — encode that as a property of the event type, not a single global
audit_logsTTL.
- Test the chain, not just the write. The failure mode worth catching in CI isn't "did an audit row get written," it's "does tampering with a row actually get detected":
public function test_altering_an_audit_log_breaks_chain_verification(): void
{
$tenant = Tenant::factory()->create();
$invoice = Invoice::factory()->for($tenant)->create();
$invoice->update(['total' => 500]);
$invoice->update(['total' => 750]);
DB::table('audit_logs')
->where('auditable_id', $invoice->id)
->where('event', 'invoice.updated')
->first();
DB::statement("UPDATE audit_logs SET after = '{\"total\":1}' WHERE auditable_id = ? LIMIT 1", [$invoice->id]);
$this->artisan('audit:verify', ['tenant' => $tenant->id])->assertExitCode(1);
}Real-World Pitfalls to Avoid#
Relying on updated_at and deleted_at as if they were an audit trail. They tell you that something changed, never who, never the before-state, and never how many intermediate changes happened between two timestamps.
No database-level enforcement, only application-level convention. An AuditLog model that throws on update() is a useful guard against your own application code — it does nothing against a script run directly against the database, which is exactly the access level an auditor is actually worried about.
A single flat table with no chain or checksum. Append-only without tamper-evidence stops accidental edits and does nothing against someone who can delete a row and reinsert a similar-looking one. The hash chain is what turns "we don't think anyone touched it" into "we can prove nothing was touched."
Logging inconsistently, feature by feature. The gap between "we log important stuff" and "we log everything that matters" is invisible until an auditor asks about the one action nobody thought to wire up — a model-level trait plus explicit domain-event logging for anything that isn't a simple field change is what closes that gap structurally instead of hoping every future feature remembers.
Storing a human-readable description instead of structured state. It's the fastest thing to ship and the first thing that blocks any later requirement to diff, redact, or query the data programmatically instead of reading it.
No answer for deletion requests. Treating "immutable forever" as a hard rule collides head-on with data-subject deletion rights the moment a customer in a regulated industry asks for both. Field-level redaction that preserves the event shape is the resolution; ignoring the tension until a legal request forces the question is not a plan.
Only logging successes. A denied access attempt is often the more interesting audit event, not the less interesting one — especially for a customer whose whole reason for asking about audit logging in the first place is "prove nobody who shouldn't have access tried to get it."
Key Takeaways#
Audit logging earns a customer's trust the same way tenant isolation and access control do in this series — by being a structural guarantee enforced in one place, not a convention scattered across features and hoped to stay consistent.
- Append-only enforced at the database grant level, not just an Eloquent guard the application itself could route around
- A hash chain over each entry, so tampering is cryptographically detectable, not just discouraged by policy
- Actor, tenant, and before/after state captured automatically through one model trait plus explicit domain-event logging — never a per-feature afterthought
- Denials logged alongside successes, since "did anyone try and fail" is as real a compliance question as "who succeeded"
- Retention and data-deletion requests reconciled explicitly through field-level redaction, not left to collide silently
- The chain itself gets tested — verifying that tampering is actually detected, not just that a write succeeded
I've built this discipline into a safety-compliance platform where a regulated customer's own auditors need to trust the trail, a bulk SIM activation system where wallet movements need a provable path back to the batch and admin action that caused them, and a document platform where knowing exactly who viewed a sensitive file is the product's own compliance promise to its customers. The regulation changes — SOC 2, GDPR, industry-specific safety compliance; the requirement that the log be at least as trustworthy as the data it describes doesn't.
If your product's answer to "can you show us who changed this and prove it wasn't edited after the fact" is currently a SELECT * FROM activity_log, get in touch about your audit architecture or see the full case studies from platforms running tamper-evident audit trails in production today.
Share this technical insight with your network
Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.
Case Study: Building Imperial Votes: A Multi-Tenant Next.js & Prisma Competition Operating System
Imperial Votes is a custom online voting platform for beauty pageants, modeling competitions, and organizer-led public contests. I built the product from scratch, starting from a blank Next.js application and turning it into a full SaaS-style platform with public competition pages, contestant profiles, paid voting, leaderboards, organizer dashboards, super-admin controls, role-based permissions, contestant applications, entry fee payments, judging workflows, branding tools, analytics, reports, notifications, and leaderboard graphic generation. The basic product idea was simple: organizers create competitions, contestants compete, voters buy vote bundles, and the leaderboard updates. The actual product became much bigger than that. Imperial Votes needed to support real organizers, real money, real applicants, different countries and currencies, different payment gateways, sponsor visibility, custom competition branding, membership tiers, internal admin workflows, and marketing tools that help organizers promote their contests. My work covered the entire platform: database architecture, backend APIs, payment logic, frontend dashboards, public pages, security and permissions, operational tooling, reporting, exports, image handling, email flows, real-time notifications, and polished UI features such as canvas-based leaderboard graphics.
Related Technical Articles
View all articles →Claude Code Cloud Sessions: Claiming the $100/$250 Credit
Anthropic is giving Pro and Max users $100–$250 to try Claude Code cloud sessions. Here's how to claim it, spend it well, and dodge the billing gotchas.

API Idempotency Keys: Stopping Duplicate Writes in Laravel
A retried POST request isn't a safe assumption — it's a duplicate charge waiting to happen. Here's how idempotency keys make retries actually safe.

Feature Flags & Progressive Delivery in Laravel SaaS
Shipping behind an if statement isn't a rollout strategy. Here's how flags become an actual release mechanism instead of permanent code debt.
Have a complex technical project in mind?
Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

