DEV3 Technologies home

Tamper-evident document audit trail

Append-only audit events where each row stores a hash of the previous one, with a verify method that detects edited or deleted history.

LaravelDatabaseaudithash-chaineloquentsecurity

Code

<?php

namespace App\Audit;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

final class AuditTrail
{
    /** Append one event. The row lock keeps the chain linear under concurrent writers. */
    public static function record(int $documentId, string $action, array $meta = [], ?Request $request = null): void
    {
        DB::transaction(function () use ($documentId, $action, $meta, $request) {
            $prev = DB::table('audit_events')->where('document_id', $documentId)
                ->orderByDesc('id')->lockForUpdate()->value('hash');

            $row = [
                'document_id' => $documentId,
                'action' => $action,
                'meta' => $meta ? json_encode($meta, JSON_UNESCAPED_SLASHES) : null,
                'ip' => $request?->ip(),
                'user_agent' => $request ? mb_substr((string) $request->userAgent(), 0, 255) : null,
                'prev_hash' => $prev,
                'created_at' => now()->format('Y-m-d H:i:s.u'),
            ];
            $row['hash'] = self::digest($row);

            DB::table('audit_events')->insert($row);
        });
    }

    /** Returns the id of the first broken row, or null when the whole chain is intact. */
    public static function verify(int $documentId): ?int
    {
        $prev = null;

        foreach (DB::table('audit_events')->where('document_id', $documentId)->orderBy('id')->cursor() as $e) {
            $row = (array) $e;
            $stored = $row['hash'];
            unset($row['id'], $row['hash']);

            if ($row['prev_hash'] !== $prev || ! hash_equals($stored, self::digest($row))) {
                return $e->id;
            }
            $prev = $stored;
        }

        return null;
    }

    private static function digest(array $row): string
    {
        ksort($row);
        unset($row['hash']);

        return hash('sha256', json_encode($row));
    }
}

How it works

What it solves

For signed documents, contracts or approvals you often need to prove that the history of what happened was not edited afterwards. A plain log table can be changed by anyone with database access. A hash chain makes silent changes detectable.

How it works

Every event row stores prev_hash, the hash of the event before it for the same document, plus its own hash, a SHA-256 over all the other columns including prev_hash. record() runs in a transaction and reads the latest hash with lockForUpdate(), so two simultaneous writers cannot both link to the same parent and fork the chain.

verify() walks the rows in order, recomputes each digest and compares it in constant time. If a row was edited, its recomputed hash differs; if a row was deleted, the next row's prev_hash no longer matches. It returns the id of the first bad row so you can show exactly where the history breaks.

Adapting it

  • Call AuditTrail::record($doc->id, 'viewed', ['signer' => $signer->id], $request) from controllers or listeners.
  • Scope the chain per tenant instead of per document by changing the where column.
  • Run verify() from a scheduled command and alert on a non-null result.

Pitfalls

  • A hash chain proves consistency, not authorship: someone who can rewrite every later row can also rebuild the chain. Anchor the latest hash somewhere outside the database for stronger guarantees.
  • JSON key order must stay stable; the digest sorts keys, so keep the column set fixed.
  • Use microsecond timestamps, as here, so ordering stays unambiguous.

How to use it

  1. Copy the migration into database/migrations/ and run php artisan migrate.
  2. Put AuditTrail.php in app/Audit/.
  3. Record events: AuditTrail::record($document->id, 'signed', ['page' => 2], $request);
  4. Check integrity: $broken = AuditTrail::verify($document->id); returns null when intact.
  5. Never update or delete rows in this table from application code.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets