Skip to content
Backend & Architecture•15 min read•Published September 26, 2026

Audit Logging Architecture in Laravel: Building Tamper-Evident History for SaaS Compliance

"We believe it was deleted correctly" isn't a compliance answer. Here's how tamper-evident audit logging in Laravel actually holds up.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover of a Laravel audit log chaining tamper-evident hashes across a multi-tenant SaaS compliance trail

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.

php
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:

sql
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.

php
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.

php
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:

php
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.

php
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:

php
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.

php
class AuditLog extends Model
{
    protected $connection = 'audit'; // separate connection, can point at a replica
}

Step-by-Step: Making the Trail Actually Query-able#

  1. 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 a WHERE tenant_id = ? filter.
php
Schema::table('audit_logs', function (Blueprint $table) {
    $table->index(['tenant_id', 'auditable_type', 'auditable_id']);
    $table->index(['tenant_id', 'actor_id', 'created_at']);
});
  1. Store before/after as 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.
  1. 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:
php
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],
        );
    }
}
  1. 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=4821 endpoint, gated by its own audit-logs.view permission (not bundled into a general admin role), turns "can you show us" into a feature instead of a ticket.
  1. 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:
php
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.

  1. 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_logs TTL.
  1. 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":
php
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.

📁 Production Case Study

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 →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid