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 inserted
  • updated — after an existing record is modified
  • deleted — after a record is deleted

If the model uses SoftDeletes, two additional events are registered automatically:

  • restored — after a soft-deleted record is restored
  • forceDeleted — 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:

  1. $auditInclude — if set, only these fields are recorded
  2. $auditExclude — these fields are always excluded
  3. $hidden auto-exclusion — the model's $hidden fields (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:

  • HasActivityLog calls DatabaseDriver::store() — sets hash and prev_hash to null, then inserts.
  • HasAuditTrail calls DatabaseDriver::storeChained() — delegates to AuditChainService::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_values and new_values are recursiveKsorted to handle inconsistent key ordering in nested arrays (MySQL JSON columns may reorder keys)
  • personal_data_accessed is sorted
  • The top-level data array is ksorted (recursively)
  • auditable_id is 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:

  1. Chain continuity — each entry's prev_hash matches the previous entry's hash
  2. 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.