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));
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_events', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('document_id')->index();
$t->string('action', 40); // viewed, signed, declined ...
$t->json('meta')->nullable();
$t->string('ip', 45)->nullable();
$t->string('user_agent', 255)->nullable();
$t->char('prev_hash', 64)->nullable(); // hash of the previous row
$t->char('hash', 64); // sha256 of this row + prev_hash
$t->timestamp('created_at', 6);
});
}
};
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
wherecolumn. - 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
- Copy the migration into
database/migrations/and runphp artisan migrate. - Put
AuditTrail.phpinapp/Audit/. - Record events:
AuditTrail::record($document->id, 'signed', ['page' => 2], $request); - Check integrity:
$broken = AuditTrail::verify($document->id);returns null when intact. - 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.