Immutability

An audit trail is only trustworthy if it cannot be altered after the fact. AuditChain enforces immutability through multiple layers — from Eloquent model guards to database-level restrictions — so that no single point of failure can compromise your audit data.

Eloquent Boot Guards

The AuditLog model registers updating and deleting event listeners in its booted() method. Both throw a RuntimeException, preventing any modification or deletion through Eloquent:

protected static function booted(): void
{
    static::updating(function (): void {
        throw new RuntimeException('Audit logs are immutable and cannot be updated.');
    });

    static::deleting(function (): void {
        throw new RuntimeException('Audit logs are immutable and cannot be deleted.');
    });
}

This means any code that attempts to call $auditLog->update(...), $auditLog->save() (after changing attributes), or $auditLog->delete() will fail immediately with an exception. This is the first line of defense and catches accidental modifications during normal application operation.

Strict $fillable Whitelist

The AuditLog model defines an explicit $fillable array that lists only the attributes set at creation time:

protected $fillable = [
    'auditable_type',
    'auditable_id',
    'event',
    'user_id',
    'ip_address',
    'user_agent',
    'old_values',
    'new_values',
    'personal_data_accessed',
    'batch_uuid',
    'context',
    'prev_hash',
    'hash',
    'created_at',
];

This prevents mass-assignment of any attributes beyond what AuditChain explicitly sets during AuditLog::create(). While the boot guards already block updates, the strict fillable list adds a second layer that limits what can be written even during creation.

Database-Level Permissions

Eloquent guards protect against modifications made through the ORM, but they cannot prevent raw SQL queries. For true immutability, use a dedicated database user with INSERT and SELECT only on the audit table.

See Database Permissions for setup instructions and example GRANT statements.

UTC Timestamps

All audit log timestamps are normalized to UTC before storage:

now()->utc()->toDateTimeString()

This is critical for hash chain integrity. The created_at timestamp is included in the hash computation, so any variation in format or timezone would produce a different hash and break verification. By always storing UTC, AuditChain ensures that:

  • Hashes are deterministic regardless of the server's timezone setting.
  • Verification produces the same result whether run locally or on a server in a different timezone.
  • Timestamp comparisons in queries behave consistently.

During verification, timestamps are re-parsed through Carbon::parse($timestamp, 'UTC')->toDateTimeString() (with explicit UTC timezone) to ensure the same normalization is applied.

What Each Layer Protects Against

Threat Eloquent Guards $fillable DB Permissions Hash Chain
$auditLog->update(...) Yes -- -- Detects
$auditLog->delete() Yes -- -- Detects
Mass assignment of extra fields -- Yes -- --
DB::statement('UPDATE ...') -- -- Yes Detects
DB::statement('DELETE ...') -- -- Yes Detects
Direct database tool access -- -- Yes Detects
Forged replacement chain -- -- -- Detects*

*Requires a secret chain seed to prevent genesis hash recomputation.

$timestamps = false

The AuditLog model sets $timestamps = false to disable Laravel's automatic updated_at management. Since audit logs are never updated, there is no need for an updated_at column. The created_at value is set explicitly by AuditChain at recording time using UTC normalization, not by Eloquent's timestamp mechanism.

Practical Implications

You cannot "fix" an audit log entry. If incorrect data is recorded, the correct approach is to create a new entry that documents the correction — not to modify the original. This is by design: an immutable audit trail must preserve the full history, including mistakes.

Pruning is the only supported deletion path. The audit:prune command is the intended way to remove old audit logs. It operates outside the Eloquent boot guards (using bulk delete queries) and requires appropriate database permissions. See Database Permissions for how to manage this alongside restricted DB users.

Octane Compatibility

AuditChainManager uses a scoped() binding instead of a singleton(). This means the manager is reset on every request in Laravel Octane environments, preventing state leakage (batch UUIDs, context metadata, disabled-logging flag) between requests.

Testing

Testing with audit logs. In test suites, use AuditChain::withoutAudit() to suppress audit logging during setup and teardown rather than trying to delete audit records:

use GrayMatter\AuditChain\Facades\AuditChain;

AuditChain::withoutAudit(function () {
    // Seed test data without creating audit logs
    User::factory()->count(50)->create();
});