Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
LaravelPHPSaaS and CloudArchitecture guide

How to design audit logs for Laravel SaaS and ERP applications

What to record, why model events are not enough, a tested hash-chained audit logger for Laravel 13, GDPR erasure with crypto-shredding, retention and packages.

Cloudsoft Zone

Updated 12 min read

Diagram of three hash-chained audit events, each storing the previous hash, with boxes showing how editing a row, deleting a row or erasing a person affects the chain
Image: Cloudsoft Zone
In this article

A useful audit log answers one question months later: who did what, to which record, when, from where, and why. In a Laravel SaaS or ERP application that means recording business actions explicitly in the service layer, inside the same database transaction as the change, in an append-only table that is chained with hashes so tampering shows up, and storing personal data in a way you can erase without breaking the chain. This guide shows a working design for each of those parts, where model-event packages fit, and the mistakes that make audit logs useless when you finally need them.

Tested with: Laravel 13.30.0, PHP 8.5.7 and SQLite in a scratch application; spatie/laravel-activitylog 5.1.0 and owen-it/laravel-auditing 14.0.6 were installed alongside for comparison.

This article is about the audit log itself. For how audit logging sits next to tenant roles and billing in a multi-tenant product, read Multi-tenant SaaS beyond data isolation: tenant RBAC, billing, audit logs.

Audit log, activity feed or application log?

Three things are often called "the log", and mixing them causes most problems:

Audit logActivity feedApplication log
PurposeEvidence for customers, auditors and investigationsProduct feature ("Priya edited this invoice")Debugging and operations
Can rows change?NeverCan be hidden or summarisedRotated and deleted
RetentionSet by contract or regulationProduct decisionDays to weeks
Who reads itTenant admins, your security team, auditorsAll users of the tenantDevelopers

You can build an activity feed from audit events, but not the other way round. Keep the audit table separate and stricter.

What to record

Record actions that change money, access, data ownership or legal state, plus sensitive reads:

  • Access: sign-ins, failed sign-ins, MFA changes, password resets, API key creation and revocation, support staff impersonating a tenant user.
  • Permissions: role grants and removals, invitations, user deactivation.
  • Money and approvals: invoice approval, credit notes, refunds, price list changes, payment method changes, purchase order approval.
  • Master data: customer and supplier bank details, tax IDs, credit limits, chart of accounts changes.
  • Data movement: exports, bulk imports, report downloads containing personal data, record deletion.
  • Configuration: tenant settings, integrations, webhook URLs, retention settings.

For each event, store the tenant, a per-tenant sequence number, the time in UTC with microseconds, the actor (user, API key, scheduled job or support agent), a stable action name such as invoice.approved, the subject type and ID, the changed fields as old and new values, and context: request ID, IP address, and the reason the user gave. The OWASP Logging Cheat Sheet lists the same "when, where, who, what" attributes.

Never record passwords, tokens, full card numbers, session IDs or secrets, even redacted halfway. Redact them by field name before anything is written.

Model events or explicit service-layer logging?

Packages that hook into Eloquent model events record every created, updated and deleted automatically. That is convenient for "what changed", but it misses things an auditor will ask about.

Diagram comparing a service-layer audit call inside a database transaction with Eloquent model events, listing what model events miss and the checks after each write
Explicit service-layer events record why; model events record what, and miss mass updates and raw SQL.

We confirmed the biggest gap in the scratch app with spatie/laravel-activitylog 5.1.0. A model update was logged; a query-builder update of the same column was not:

PHP
$customer->update(['credit_limit' => 8000]);
// activity_log: 2 rows (created, updated)

Customer::where('id', $customer->id)->update(['credit_limit' => 50000]);
// activity_log: still 2 rows, credit limit now 50000

This is documented Laravel behaviour: mass updates do not fire saving, saved, updating or updated events. Raw SQL, DB::table() calls, imports and migrations are equally invisible, and model events never know the reason for a change, who approved it, or that someone exported 4,000 customer records.

The design that holds up is both: an explicit audit call in each service that performs a business action, written in the same transaction as the change, and optionally a model-event package for low-risk "what changed" history on less critical models.

A working implementation

The schema keeps queries fast and the chain verifiable. changes and context are longText, not json, because MySQL normalises JSON values (key order and whitespace), so the bytes you read back would differ from the bytes you hashed.

PHP
Schema::create('audit_events', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('tenant_id');
    $table->unsignedBigInteger('seq');                 // position in this tenant's chain
    $table->timestamp('occurred_at', 6);
    $table->string('actor_type', 20);                 // user, api_key, system, support
    $table->string('actor_id', 64)->nullable();
    $table->string('action', 100);                     // invoice.approved, role.granted ...
    $table->string('subject_type', 100)->nullable();
    $table->string('subject_id', 64)->nullable();
    $table->longText('changes')->nullable();           // {field: [old, new]}
    $table->longText('context')->nullable();           // request id, ip, reason
    $table->string('data_subject_id', 64)->nullable(); // whose personal data is inside
    $table->char('prev_hash', 64);
    $table->char('hash', 64);

    $table->unique(['tenant_id', 'seq']);
    $table->index(['tenant_id', 'occurred_at']);
    $table->index(['tenant_id', 'subject_type', 'subject_id']);
    $table->index(['tenant_id', 'actor_type', 'actor_id']);
    $table->index(['tenant_id', 'action']);
});

Schema::create('audit_chain_heads', function (Blueprint $table) {
    $table->unsignedBigInteger('tenant_id')->primary();
    $table->unsignedBigInteger('seq')->default(0);
    $table->char('hash', 64);
});

The logger locks the tenant's chain head, appends the next event and moves the head, all inside one transaction:

PHP
<?php

namespace App\Audit;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

final class AuditLogger
{
    private const PERSONAL_FIELDS = ['email', 'name', 'phone', 'address'];
    private const SECRET_FIELDS = ['password', 'remember_token', 'two_factor_secret', 'api_token'];

    public function __construct(private PersonalDataVault $vault) {}

    public function record(
        int $tenantId,
        AuditActor $actor,
        string $action,
        ?string $subjectType = null,
        ?string $subjectId = null,
        array $changes = [],
        array $context = [],
        ?string $dataSubjectId = null,
    ): int {
        $changes = $this->prepareChanges($changes, $dataSubjectId);
        $context += array_filter([
            'request_id' => request()?->header('X-Request-Id'),
            'ip' => request()?->ip(),
        ]);

        return DB::transaction(function () use ($tenantId, $actor, $action, $subjectType, $subjectId, $changes, $context, $dataSubjectId) {
            DB::table('audit_chain_heads')->insertOrIgnore([
                'tenant_id' => $tenantId, 'seq' => 0, 'hash' => str_repeat('0', 64),
            ]);

            // Serialise writers per tenant so the chain never forks.
            $head = DB::table('audit_chain_heads')->where('tenant_id', $tenantId)->lockForUpdate()->first();

            $row = [
                'tenant_id' => $tenantId,
                'seq' => $head->seq + 1,
                'occurred_at' => Carbon::now('UTC')->format('Y-m-d H:i:s.u'),
                'actor_type' => $actor->type,
                'actor_id' => $actor->id,
                'action' => $action,
                'subject_type' => $subjectType,
                'subject_id' => $subjectId,
                'changes' => $changes ? json_encode($changes, JSON_THROW_ON_ERROR) : null,
                'context' => $context ? json_encode($context, JSON_THROW_ON_ERROR) : null,
                'data_subject_id' => $dataSubjectId,
                'prev_hash' => $head->hash,
            ];
            $row['hash'] = self::hashFor($row);

            DB::table('audit_events')->insert($row);
            DB::table('audit_chain_heads')->where('tenant_id', $tenantId)
                ->update(['seq' => $row['seq'], 'hash' => $row['hash']]);

            return $row['seq'];
        });
    }

    public static function hashFor(array $row): string
    {
        $fields = ['tenant_id', 'seq', 'occurred_at', 'actor_type', 'actor_id', 'action',
            'subject_type', 'subject_id', 'changes', 'context', 'data_subject_id', 'prev_hash'];

        $canonical = implode("\x1f", array_map(fn ($f) => (string) ($row[$f] ?? ''), $fields));

        return hash_hmac('sha256', $canonical, config('audit.chain_key'));
    }

    private function prepareChanges(array $changes, ?string $dataSubjectId): array
    {
        foreach ($changes as $field => [$old, $new]) {
            if (in_array($field, self::SECRET_FIELDS, true)) {
                $changes[$field] = ['[redacted]', '[redacted]'];
            } elseif (in_array($field, self::PERSONAL_FIELDS, true) && $dataSubjectId) {
                $changes[$field] = [
                    $old === null ? null : $this->vault->encrypt($dataSubjectId, $old),
                    $new === null ? null : $this->vault->encrypt($dataSubjectId, $new),
                ];
            }
        }

        return $changes;
    }
}

AuditActor is a small readonly value object with type and id. config('audit.chain_key') reads an AUDIT_CHAIN_KEY environment variable that is separate from APP_KEY, so someone with only database access cannot recompute valid hashes.

A business action then records its own event. Because DB::transaction() nests as a savepoint, a failed approval rolls back both the invoice change and its audit row:

PHP
final class ApproveInvoice
{
    public function __construct(private AuditLogger $audit) {}

    public function __invoke(User $approver, Invoice $invoice, string $reason): void
    {
        DB::transaction(function () use ($approver, $invoice, $reason) {
            $invoice = Invoice::whereKey($invoice->id)->lockForUpdate()->firstOrFail();

            if ($invoice->status !== 'pending') {
                throw ValidationException::withMessages(['status' => 'Only pending invoices can be approved.']);
            }

            $invoice->update(['status' => 'approved', 'approved_by' => $approver->id]);

            $this->audit->record(
                tenantId: $invoice->tenant_id,
                actor: new AuditActor('user', (string) $approver->id),
                action: 'invoice.approved',
                subjectType: 'invoice',
                subjectId: (string) $invoice->id,
                changes: ['status' => ['pending', 'approved']],
                context: ['reason' => $reason, 'amount' => $invoice->total],
            );
        });
    }
}

In the scratch run, approving a second time threw the validation error and left exactly one audit event.

Tamper evidence with a hash chain

Each event's hash covers its own fields plus the previous event's hash, so changing or removing any row breaks verification from that point on. The command below recomputes the chain for one tenant:

PHP
#[Signature('audit:verify {tenant : Tenant id}')]
#[Description('Recompute the audit hash chain for one tenant and report the first broken link')]
class VerifyAuditChain extends Command
{
    public function handle(): int
    {
        $tenantId = (int) $this->argument('tenant');
        $expectedPrev = str_repeat('0', 64);
        $expectedSeq = 1;
        $checked = 0;

        foreach (DB::table('audit_events')->where('tenant_id', $tenantId)->orderBy('seq')->lazyById(1000, 'id') as $event) {
            $row = (array) $event;

            if ((int) $row['seq'] !== $expectedSeq) {
                $this->error("Gap: expected seq {$expectedSeq}, found {$row['seq']} (deleted rows?)");
                return self::FAILURE;
            }
            if ($row['prev_hash'] !== $expectedPrev || ! hash_equals(AuditLogger::hashFor($row), $row['hash'])) {
                $this->error("Broken at seq {$row['seq']} (event id {$row['id']}): row was altered");
                return self::FAILURE;
            }

            $expectedPrev = $row['hash'];
            $expectedSeq++;
            $checked++;
        }

        $head = DB::table('audit_chain_heads')->where('tenant_id', $tenantId)->first();
        if ($head && $head->hash !== $expectedPrev) {
            $this->error('Chain head does not match the last event (rows removed from the end?)');
            return self::FAILURE;
        }

        $this->info("OK: {$checked} events verified for tenant {$tenantId}");
        return self::SUCCESS;
    }
}

We edited one row and then deleted the newest row directly in the database; the command caught both.

Terminal output from a scratch run: audit:verify passes, still passes after a GDPR key erasure, then reports a broken chain after a row edit and a deletion
Our scratch run on Laravel 13.30: erasure keeps the chain valid, while edits and deletions are caught.

A hash chain is tamper-evident, not tamper-proof. Someone with database write access and the chain key could rewrite everything after a point. Close that gap in three ways:

  • Least privilege. The application's database user gets INSERT and SELECT on audit_events, never UPDATE or DELETE; retention runs under a separate account. On MySQL you can also add BEFORE UPDATE and BEFORE DELETE triggers that raise SIGNAL SQLSTATE '45000'.
  • Anchoring. Copy each tenant's chain head (sequence and hash) once a day to write-once storage such as an S3 bucket with Object Lock in compliance mode. A rewritten chain will no longer match yesterday's anchor.
  • Scheduled verification. Run audit:verify nightly for every tenant and alert on failure.

The per-tenant chain head serialises writes for one tenant. That is fine for business actions (a few per second per tenant), but do not route high-volume events such as every page view through it.

Privacy and GDPR erasure

Audit logs usually contain personal data, so the GDPR applies if you serve people in the EU or EEA, and the UK GDPR applies in the UK. Article 5 requires data minimisation and storage limitation, and Article 17 gives people a right to erasure. Erasure is not absolute: Article 17(3) allows you to keep data needed for a legal obligation or for legal claims, which covers many financial records. Decide per event type with whoever handles data protection.

You cannot simply blank a field in a hash-chained row without breaking the chain. The implementation above uses crypto-shredding: personal fields are encrypted with a per-person key stored in a separate table, and erasure deletes that key. The ciphertext stays, so the chain still verifies, but the email shows as [erased].

PHP
final class PersonalDataVault
{
    public function encrypt(string $dataSubjectId, mixed $value): string
    {
        return 'pd:'.$this->encrypterFor($dataSubjectId, create: true)->encrypt($value);
    }

    public function forget(string $dataSubjectId): void
    {
        DB::table('audit_subject_keys')->where('data_subject_id', $dataSubjectId)->delete();
    }

    // decrypt() returns '[erased]' when the key is gone; encrypterFor() creates
    // an AES-256-GCM key per person, stored encrypted with APP_KEY.
}

Two cautions. Whether crypto-shredding counts as erasure is a legal judgement, not a technical one, so record the decision. And keys must not survive in backups indefinitely, or the data is recoverable; give the key table a shorter backup retention than the audit table. Other good habits: store user IDs rather than names in actor_id, truncate or hash IP addresses after a set period if you do not need them, and never copy whole request bodies into context.

Open 1914 logbook of the USS Prairie with hourly handwritten weather entries, a typed account of events pasted in, officers' signatures and an 'examined and found correct' line
A ship's log has worked like an audit trail for centuries: timed entries, a named author for each, and corrections added rather than erased.

Retention and archiving

Set retention per tenant plan and per event category, and write it down. Some regimes give a floor: PCI DSS requirement 10.5.1 asks for at least 12 months of audit log history with the latest three months immediately available. Contracts with enterprise customers often specify more.

Retention must not break verification. Archive whole ranges instead of deleting rows from the middle: export events older than the cut-off for a tenant to compressed files in object storage, record the last archived sequence number and hash in an audit_archives table, then delete that range under the retention account. Verification then starts from the archived hash instead of zeros.

Querying the log

The indexes above serve the four questions people actually ask: what happened to this record (subject_type, subject_id), what did this person do (actor_type, actor_id), what happened in this period (occurred_at), and every event of one kind (action). All start with tenant_id, because every query must be scoped to a tenant. Paginate with the sequence number as a cursor rather than offsets, cap page sizes, and decrypt personal fields only when an authorised viewer opens a single event. Export to your SIEM or data warehouse if you need full-text search across tenants; do not add it to the transactional table.

Packages that exist today

PackageVersion testedApproachGood for
spatie/laravel-activitylog5.1.0 (PHP 8.4+, Laravel 13)LogsActivity trait on models plus an activity() helper for manual entries; table activity_log; activitylog:clean with clean_after_days (default 365)Activity feeds and change history
owen-it/laravel-auditing14.0.6 (Laravel 11 to 13)Auditable contract on models; table audits; IP, user agent and URL resolvers; optional queued writesField-level model change history

Version 5 of the Spatie package moved classes: the trait is now Spatie\Activitylog\Models\Concerns\LogsActivity and options live in Spatie\Activitylog\Support\LogOptions, so v4 code needs its imports updated. Neither package provides hash chaining, per-tenant sequences or crypto-shredding, and both are model-event driven, so they inherit the mass-update gap. Use them for "what changed" history and keep a small explicit logger, like the one above, for evidence.

Common mistakes

  • Logging outside the transaction. An audit row for a change that rolled back, or a change with no audit row, both destroy trust in the log.
  • Queueing audit writes for critical actions. A failed job means a missing event. Write synchronously in the transaction; queue only exports and anchoring.
  • Storing the full model snapshot. It multiplies personal data and storage. Store changed fields only.
  • Letting tenant admins delete events. Give them read and export, nothing else.
  • Using local time. Store UTC with microseconds and convert for display.
  • Forgetting non-user actors. Scheduled jobs, webhooks and support impersonation need their own actor types, or "who" becomes blank.

When not to build this

An internal tool with five users and no regulated data does not need hash chains; a package such as laravel-activitylog is enough. If you already send events to a managed audit or SIEM service that offers write-once storage and you only need evidence of access, use it rather than duplicating it. And if you need tamper-proof guarantees against your own administrators, you need an external, independently operated store, not only a better table.

Retrofitting audit logging into a live ERP is the hard part: finding every place money, access and master data change, including old raw SQL. Cloudsoft Zone's SaaS and ERP team can map those paths and add explicit audit events without changing behaviour.

Building or extending a multi-tenant SaaS or ERP product on Laravel? Cloudsoft Zone designs and builds SaaS and ERP applications with tenant-safe permissions, audit trails that stand up to customer reviews, and retention you can explain.

Frequently asked questions

  • Should I use model events or explicit logging for audit trails in Laravel?

    Use explicit logging in the service layer for business actions such as approvals, permission changes and exports, written in the same transaction as the change. Model events are fine for general change history, but they do not fire for query-builder mass updates or raw SQL and never know the reason for a change.

  • Does a hash chain make an audit log tamper-proof?

    No, it makes tampering detectable. Combine it with an insert-only database user, a chain key kept outside the database, daily anchoring of the chain head to write-once storage, and a scheduled verification job.

  • How do I handle GDPR erasure requests in an audit log?

    First check whether you must keep the record for a legal obligation or legal claims, which Article 17(3) allows. For data you must erase, encrypt personal fields with a per-person key and delete the key, so the rows and hash chain stay intact but the values become unreadable. Confirm the approach with whoever handles data protection.

  • How long should audit logs be kept?

    As long as your contracts and regulations require, and no longer. PCI DSS asks for at least 12 months with three months immediately available; many B2B contracts specify one to seven years. Archive old ranges to object storage rather than deleting rows from the middle of the chain.

  • Can I store audit logs in a MySQL JSON column?

    Not if you hash them. MySQL normalises JSON values, changing key order and whitespace, so the text you read back differs from what you hashed. Store the encoded JSON in a LONGTEXT column, or hash a canonical form you can rebuild exactly.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Eloquent: mass updates and model events (13.x) — Laravel
  2. Logging Cheat Sheet — OWASP
  3. The JSON data type: normalization — Oracle MySQL
  4. Regulation (EU) 2016/679 (GDPR) — EUR-Lex, 2016-04-27
  5. Locking objects with Object Lock — Amazon Web Services
  6. spatie/laravel-activitylog — Spatie, 2026-08-12
  7. owen-it/laravel-auditing — OWEN IT, 2026-06-20
  8. PCI DSS document library — PCI Security Standards Council

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Blue plush elePHPant toy, the PHP community mascot, on a plain background

    Troubleshooting guideLaravel

    How to find and fix N+1 queries in Laravel Eloquent

    N+1 queries make Laravel pages slow as data grows. Learn to detect them with preventLazyLoading and query listeners, then fix them with eager loading, withCount and Laravel 13's automatic eager loading.

    By Cloudsoft Zone

  • Plush elePHPant toy from Heroku, a version of the PHP community mascot, photographed close up

    Laravel

    Upgrading from Laravel 12 to Laravel 13: a practical guide

    Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.

    By Cloudsoft Zone

  • Blue plush elephant toy, the PHP community mascot known as the elePHPant

    ComparisonLaravel

    Laravel Sanctum vs Passport: which API authentication package to use

    Sanctum and Passport both authenticate Laravel APIs, but they solve different problems. This comparison explains tokens, SPA cookies, OAuth2 grants, expiry and clean-up in Laravel 13, and when each package is the right choice.

    By Cloudsoft Zone