How It Works
AuditChain works by hooking into Eloquent's lifecycle events, building a structured payload for each change, and (in full mode) linking entries together with SHA-256 hashes. This page walks through the internals.
Eloquent Event Hooks
When you add HasActivityLog or HasAuditTrail to a model, the base HasAuditableEvents trait registers listeners on Eloquent's model events during boot:
created— after a new record is insertedupdated— after an existing record is modifieddeleted— after a record is deleted
If the model uses SoftDeletes, two additional events are registered automatically:
restored— after a soft-deleted record is restoredforceDeleted— after a soft-deleted record is permanently deleted
If log_reads is enabled in config, retrieved events are also captured (use with caution — this is very verbose).
Before recording any event, AuditChain checks AuditChainManager::isLoggingDisabled(). This is how AuditChain::withoutAudit() suppresses logging during seeds and imports.
Payload Building
For each event, AuditChain builds a payload array:
[
'auditable_type' => 'App\Models\Invoice', // morph class
'auditable_id' => '42', // always cast to string
'event' => 'updated',
'user_id' => 1, // Auth::id()
'ip_address' => '192.168.1.1', // null in console
'user_agent' => 'Mozilla/5.0...', // null in console
'old_values' => ['status' => 'draft'],
'new_values' => ['status' => 'published'],
'personal_data_accessed' => ['email'], // if applicable
'batch_uuid' => 'abc-123', // if inside batch()
'context' => ['source' => 'api'], // if set
'created_at' => '2026-02-20 14:30:00', // UTC
]
Old values are captured from the model's raw original attributes (via getRawOriginal()), filtered to only include fields that actually changed. New values come from getChanges() for updates, or getAttributes() for creates.
Field Filtering
Three layers of field filtering are applied to old/new values:
$auditInclude— if set, only these fields are recorded$auditExclude— these fields are always excluded$hiddenauto-exclusion — the model's$hiddenfields (passwords, API tokens) are automatically excluded
Dispatch: Light vs. Full
After the payload is built, the dispatchAuditLog() method determines how it's stored. This is the only difference between the two modes:
HasActivityLogcallsDatabaseDriver::store()— setshashandprev_hashtonull, then inserts.HasAuditTrailcallsDatabaseDriver::storeChained()— delegates toAuditChainService::record(), which computes the hash chain within a database transaction.
When queue is enabled (the default), both modes dispatch a RecordAuditLog job instead of writing synchronously.
Hash Chain Computation
This section only applies to models using HasAuditTrail.
The Hash Formula
Each audit log's hash is a SHA-256 digest of a deterministic JSON payload:
hash = SHA-256(json_encode(sorted_data))
The JSON encoding uses strict flags: JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.
The data included in the hash (alphabetically sorted via recursiveKsort, a private method in AuditChainService, for nested arrays):
| Field | Source |
|---|---|
auditable_id |
Model primary key (as string) |
auditable_type |
Model morph class |
event |
Event name |
ip_address |
Request IP |
new_values |
Changed values (ksorted) |
old_values |
Previous values (ksorted) |
personal_data_accessed |
Personal fields involved (sorted) |
prev_hash |
Hash of previous entry in chain |
timestamp |
UTC datetime string |
user_agent |
Request user agent |
user_id |
Authenticated user ID |
Intentionally excluded from the hash: batch_uuid and context. These are operational metadata — changing how you group or annotate audit logs should never invalidate the chain.
Deterministic Hashing
Several steps ensure the same data always produces the same hash:
- All timestamps are normalized to UTC via
Carbon::parse($timestamp, 'UTC')->toDateTimeString()(explicit UTC timezone) old_valuesandnew_valuesarerecursiveKsorted to handle inconsistent key ordering in nested arrays (MySQL JSON columns may reorder keys)personal_data_accessedissorted- The top-level data array is
ksorted (recursively) auditable_idis always cast to string
Genesis Hash
The very first entry in the chain has no predecessor. Its prev_hash is derived from a configurable seed:
prev_hash = SHA-256(chain_seed)
The seed defaults to 'genesis' but should be set to a secret value in production via the AUDIT_CHAIN_SEED environment variable. A predictable seed weakens tamper-evidence — if an attacker knows the seed, they can reconstruct the genesis hash and forge a chain from scratch.
AUDIT_CHAIN_SEED=your-random-secret-value
Global Chain with Locking
AuditChain maintains a single global chain across all models — not a per-model chain. This means an Invoice update and a User update share the same chain, making it harder to tamper with a subset of records without breaking the entire chain.
To prevent race conditions, record() uses a sentinel table (audit_chain_state) instead of scanning the audit_logs table. This single-row table stores the last_hash of the most recent chained entry. The record() method runs inside a database transaction and uses SELECT last_hash FOR UPDATE on the sentinel row:
DB::transaction(function () use ($data) { // SELECT last_hash FROM audit_chain_state FOR UPDATE $prevHash = $this->getPreviousHash(); $data['prev_hash'] = $prevHash; $data['hash'] = $this->computeHash($data); AuditLog::create($data); // UPDATE audit_chain_state SET last_hash = $data['hash'] $this->updateLastHash($data['hash']); });
The FOR UPDATE lock on the sentinel row ensures that concurrent requests serialize when appending to the chain, preventing two entries from claiming the same predecessor. This approach is O(1) regardless of the number of audit logs, unlike scanning the audit_logs table which would degrade as the table grows.
Verification
Chain verification walks through every chained audit log (where hash IS NOT NULL) in chronological order and checks two things:
- Chain continuity — each entry's
prev_hashmatches the previous entry'shash - Hash integrity — recomputing the hash from the stored data matches the stored
hash
Verification processes logs in chunks of 1000 to keep memory usage constant. See Chain Verification for how to run and automate it.
Immutability
The AuditLog model enforces immutability at the Eloquent layer — updating and deleting events throw a RuntimeException. For true defense-in-depth, use a dedicated database user with INSERT and SELECT permissions only on the audit logs table. See Security for details.