Audit Trails

Tracking Every Change for Compliance and Debugging


Something changed. An order was modified. A price was updated. A record was deleted. The question is: who did it, when, and why?

Without audit trails, these questions are unanswerable. With them, you have a complete history of every significant change in your system. Audit trails aren't just for compliance. They're for debugging, accountability, and understanding how your data evolved over time. They're a crucial component of maintaining a single source of truth in your organisation.


Immutable Event Ledger
SHA-256 Verifiable
Select an event to inspect payload

The Constraint: When History Matters

Most applications treat data as current state. You have a customer record. It has a status. The status is "Active". That's all you know.

Then someone asks: "When did this customer become active? Who approved it? What was the status before? Why did it change three times last Tuesday?"

Without audit trails, you're guessing. Checking email threads. Asking around. Hoping someone remembers. The data exists, but the history is gone.

This becomes critical in several contexts:

Regulatory compliance

GDPR requires you to demonstrate lawful basis for data changes. SOX mandates complete audit trails for financial data. HIPAA requires tracking all access to patient records. PCI-DSS demands logging of all access to cardholder data. ISO 27001 requires evidence of access control effectiveness.

When auditors ask "who accessed this record and when," silence is not an acceptable answer.

Debugging production issues

"This data is wrong." Without audit trails, finding the cause is guesswork. With them, you can trace the history: see when the data changed, what it changed from, what triggered the change, and whether it was a user action, an API call, or an automated process.

The difference between "I don't know what happened" and "Here's exactly what happened at 14:32:07."

Accountability and disputes

When everyone knows changes are tracked, behaviour changes. People are more careful. Disputes can be resolved with facts rather than memory. "You never approved that" becomes "You approved it on 3rd January at 09:47 from IP 192.168.1.42."

Business intelligence

Audit data reveals patterns: which records change frequently, who's most active, what time of day changes happen, how long records stay in each state. This informs process improvements and identifies bottlenecks.


The Naive Approach: What Breaks at Scale

The tutorial approach to audit logging looks simple: add a created_by and updated_by column to each table, maybe an updated_at timestamp. Some applications add a separate "audit_log" table with a text description of what changed.

This breaks in predictable ways:

Approach The Problem When It Bites You
updated_by column Only tracks last change, not history First time someone asks "what was the value before?"
Text description logs "User changed status" tells you nothing about old/new values First debugging session requiring actual values
JSON blob of changes Impossible to query efficiently First report requiring "all changes to field X"
Same database as application Audit data grows faster than operational data First time audit queries slow down the application
Synchronous writes Every save operation now has double the latency First high-traffic period
Application-only logging Direct database updates bypass the audit trail First data migration or bulk fix

The most common failure mode: audit trails that capture something, but not enough to actually answer the questions you'll be asked. Partial history is often worse than no history, because it creates false confidence.

The real constraint: Audit logs need to capture enough detail to reconstruct history, without impacting application performance, while remaining queryable for compliance reports, and staying immutable once written. Getting all four right simultaneously is the hard part.


The Robust Pattern: Structured Audit Records

A production-grade audit trail captures structured, queryable records of every significant change. Each record is a complete snapshot of what happened, when, by whom, and what the data looked like before and after.

What to capture

Every audit record should include these fields:

The essentials
  • Timestamp: When the change occurred (UTC, microsecond precision)
  • Actor: User ID, system process, or API client that made the change
  • Action: Create, update, delete, or custom action (approve, submit, etc.)
  • Entity: Table/model name and primary key of the affected record
  • Old values: The field values before the change (null for creates)
  • New values: The field values after the change (null for deletes)
Additional context
  • Request ID: Correlation ID linking related changes from a single request
  • IP address: Source of the request
  • User agent: Browser/client identification
  • Reason: Optional field for user-provided justification
  • Tenant: For multi-tenant systems, which tenant the change belongs to
  • Source: Web UI, API, import, automated process, data migration

What not to capture

Not everything belongs in an audit trail:

Sensitive data in plain text: Passwords, API keys, credit card numbers. Log that a change occurred, not the value.
High-frequency ephemeral data: Cache entries, session tokens, temporary calculation results.
Derived data: Computed fields, cached aggregates, anything that can be recalculated from source data.
Bulk system operations: Nightly batch jobs that touch every record don't need per-record audit entries. Log the job execution instead.

Balance completeness against storage, performance, and signal-to-noise ratio. Capture what matters. Skip what doesn't.


Implementation: Schema Design

A well-designed audit table schema supports efficient queries while accommodating different entity types. The schema design principles here follow the same patterns we cover in data modelling. Here's a battle-tested structure:

Core audit table schema (PostgreSQL)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    recorded_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Who made the change
    actor_type      VARCHAR(50) NOT NULL,  -- 'user', 'system', 'api_client'
    actor_id        BIGINT,                -- NULL for anonymous/system
    actor_name      VARCHAR(255),          -- Denormalised for query convenience

    -- What was changed
    entity_type     VARCHAR(100) NOT NULL, -- 'orders', 'customers', 'invoices'
    entity_id       BIGINT NOT NULL,
    action          VARCHAR(50) NOT NULL,  -- 'create', 'update', 'delete', 'approve'

    -- The change itself
    old_values      JSONB,                 -- NULL for creates
    new_values      JSONB,                 -- NULL for deletes
    changed_fields  TEXT[],                -- Array of field names that changed

    -- Context
    request_id      UUID,                  -- Correlation ID
    ip_address      INET,
    user_agent      TEXT,
    source          VARCHAR(50),           -- 'web', 'api', 'import', 'migration'
    reason          TEXT,                  -- User-provided justification

    -- Multi-tenant support
    tenant_id       BIGINT,

    -- Indexing support
    recorded_date   DATE GENERATED ALWAYS AS (recorded_at::date) STORED
);

-- Essential indexes
CREATE INDEX idx_audit_entity ON audit_log (entity_type, entity_id, recorded_at DESC);
CREATE INDEX idx_audit_actor ON audit_log (actor_type, actor_id, recorded_at DESC);
CREATE INDEX idx_audit_date ON audit_log (recorded_date, entity_type);
CREATE INDEX idx_audit_tenant ON audit_log (tenant_id, recorded_at DESC);
CREATE INDEX idx_audit_request ON audit_log (request_id) WHERE request_id IS NOT NULL;

Several design decisions deserve explanation:

  • JSONB for old/new values: Flexible enough to handle any entity structure, queryable with PostgreSQL's JSON operators, compressed automatically.
  • changed_fields array: Enables efficient filtering for "all changes to field X" without parsing JSON.
  • Denormalised actor_name: Users get renamed, deleted, merged. The audit log should show who made the change at the time, not require a join to a potentially-changed user table.
  • Generated date column: Enables partition pruning for date-range queries without function calls in WHERE clauses.
  • BIGSERIAL primary key: Guaranteed ordering, efficient range scans, no UUID overhead for a table that will have billions of rows.

Table partitioning for scale

Audit tables grow indefinitely. A system processing 10,000 changes per day accumulates 3.6 million records per year. At 10 years of retention, that's 36 million records before considering growth. Partitioning is not optional.

Partitioning by date (PostgreSQL 12+)
CREATE TABLE audit_log (
    -- ... same columns as above ...
) PARTITION BY RANGE (recorded_date);

-- Create partitions for each month
CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- Automate partition creation with pg_partman or similar

Monthly partitions provide a good balance: small enough for efficient maintenance, large enough to avoid excessive partition overhead. Older partitions can be moved to cheaper storage or archived without touching active data.


Implementation Approaches

There are four primary methods for capturing audit data, each with distinct trade-offs:

Database triggers

Triggers fire automatically on INSERT, UPDATE, DELETE operations. They capture every change regardless of how it was made: application code, direct SQL, migrations, admin tools.

Strengths: Impossible to bypass from application code. Captures bulk operations and direct database access. Single implementation covers all tables.

Weaknesses: Limited context (no user ID unless passed via session variables). Harder to test. Can impact database performance under high load.

Application-level logging

The application explicitly captures changes before/after saving records. ORM model events (Eloquent observers, Django signals) make this easier to implement consistently.

Strengths: Full context available (user, request, reason). Easy to test. Can capture business-level events, not just data changes.

Weaknesses: Bypassable if developers use raw SQL. Requires discipline to implement on every model. Easy to forget when adding new entities.

Event sourcing

Instead of storing current state and logging changes, store the events that produce the state. Current state is derived by replaying events. The event log is the source of truth.

Strengths: Complete history by design. Can reconstruct state at any point in time. Natural fit for domains with complex business events.

Weaknesses: Fundamentally different architecture. Queries require projections. Steep learning curve. Overkill for simple CRUD applications.

Change data capture (CDC)

External tools monitor the database transaction log (WAL in PostgreSQL) and stream changes to a separate system. Debezium, AWS DMS, and similar tools enable this.

Strengths: Zero application changes. Captures everything. No performance impact on write path. Changes stream in real-time.

Weaknesses: Requires additional infrastructure. Limited context (only what's in the database). Complex to set up and operate.

Recommended approach: Defence in depth

For critical systems, combine approaches:

1

Application-level logging as the primary mechanism. Captures business context, user identity, request correlation. Implemented via model observers or middleware.


2

Database triggers as a safety net. Catches any changes that bypass the application: direct SQL fixes, migrations, bulk operations. Flags these as "system" changes for review.


3

CDC streaming to a separate audit database for high-volume systems. Keeps audit writes off the critical path. Provides redundancy if application logging fails.

This creates a complete audit trail that survives any single point of failure.


Implementation: Application-Level Logging

In Laravel, model observers provide clean hooks for audit logging. Here's a production pattern:

Audit trait for Eloquent models
trait Auditable
{
    public static function bootAuditable(): void
    {
        static::created(fn ($model) => $model->recordAudit('create'));
        static::updated(fn ($model) => $model->recordAudit('update'));
        static::deleted(fn ($model) => $model->recordAudit('delete'));
    }

    protected function recordAudit(string $action): void
    {
        $oldValues = $action === 'create' ? null : $this->getOriginal();
        $newValues = $action === 'delete' ? null : $this->getAttributes();

        // Filter out unchanged values and non-auditable fields
        if ($action === 'update') {
            $changedFields = array_keys($this->getDirty());
            $oldValues = Arr::only($oldValues, $changedFields);
            $newValues = Arr::only($newValues, $changedFields);
        }

        // Skip if nothing actually changed
        if ($action === 'update' && empty($changedFields)) {
            return;
        }

        AuditLog::create([
            'entity_type'    => $this->getTable(),
            'entity_id'      => $this->getKey(),
            'action'         => $action,
            'old_values'     => $this->sanitiseForAudit($oldValues),
            'new_values'     => $this->sanitiseForAudit($newValues),
            'changed_fields' => $changedFields ?? null,
            'actor_type'     => $this->resolveActorType(),
            'actor_id'       => auth()->id(),
            'actor_name'     => auth()->user()?->name ?? 'System',
            'request_id'     => request()->header('X-Request-ID'),
            'ip_address'     => request()->ip(),
            'user_agent'     => request()->userAgent(),
            'source'         => $this->resolveSource(),
            'tenant_id'      => $this->tenant_id ?? null,
        ]);
    }

    protected function sanitiseForAudit(?array $values): ?array
    {
        if ($values === null) return null;

        // Remove sensitive fields
        $redacted = array_fill_keys($this->auditRedacted ?? [], '[REDACTED]');
        return array_merge(
            Arr::except($values, $this->auditExcluded ?? []),
            $redacted
        );
    }

    protected function resolveActorType(): string
    {
        if (app()->runningInConsole()) return 'system';
        if (auth()->guard('api')->check()) return 'api_client';
        return 'user';
    }

    protected function resolveSource(): string
    {
        if (app()->runningInConsole()) return 'console';
        if (request()->is('api/*')) return 'api';
        return 'web';
    }
}

Usage on a model:

class Order extends Model
{
    use Auditable;

    // Fields to exclude from audit (timestamps, cached values)
    protected array $auditExcluded = ['updated_at', 'search_vector'];

    // Fields to redact (show that they changed, but not the value)
    protected array $auditRedacted = ['payment_token'];
}

Database trigger implementation

For defence in depth, add a PostgreSQL trigger that catches changes the application misses:

Generic audit trigger (PostgreSQL)
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
    actor_id BIGINT;
    old_data JSONB;
    new_data JSONB;
BEGIN
    -- Try to get actor from session variable (set by application)
    actor_id := NULLIF(current_setting('app.current_user_id', true), '')::BIGINT;

    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        new_data := NULL;
    ELSIF TG_OP = 'INSERT' THEN
        old_data := NULL;
        new_data := to_jsonb(NEW);
    ELSE
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
    END IF;

    INSERT INTO audit_log (
        entity_type, entity_id, action,
        old_values, new_values,
        actor_type, actor_id, actor_name,
        source
    ) VALUES (
        TG_TABLE_NAME,
        COALESCE((new_data->>'id')::BIGINT, (old_data->>'id')::BIGINT),
        LOWER(TG_OP),
        old_data, new_data,
        CASE WHEN actor_id IS NULL THEN 'system' ELSE 'user' END,
        actor_id,
        COALESCE(
            (SELECT name FROM users WHERE id = actor_id),
            'System'
        ),
        'database'
    );

    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

-- Apply to tables
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

The application sets the session variable before database operations:

// In middleware or service provider
DB::statement("SET LOCAL app.current_user_id = ?", [auth()->id()]);

This way, even direct SQL updates get attributed to the correct user when possible.


Querying Audit Data

An audit trail is only useful if you can query it efficiently. Common query patterns and their optimised implementations:

Record history

Show all changes to a specific record over time. The most common query.

SELECT
    recorded_at,
    action,
    actor_name,
    old_values,
    new_values,
    changed_fields,
    source,
    ip_address
FROM audit_log
WHERE entity_type = 'orders'
  AND entity_id = 12345
ORDER BY recorded_at DESC
LIMIT 100;

-- Uses idx_audit_entity, efficient even with billions of rows

User activity

What has a specific user done? Essential for security reviews and incident investigation.

SELECT
    recorded_at,
    entity_type,
    entity_id,
    action,
    changed_fields
FROM audit_log
WHERE actor_type = 'user'
  AND actor_id = 789
  AND recorded_at >= NOW() - INTERVAL '7 days'
ORDER BY recorded_at DESC;

-- Uses idx_audit_actor, efficient for user-centric queries

Field-specific changes

All changes to a specific field across all records. Critical for tracking sensitive data modifications.

SELECT
    recorded_at,
    entity_id,
    actor_name,
    old_values->>'status' as old_status,
    new_values->>'status' as new_status
FROM audit_log
WHERE entity_type = 'orders'
  AND 'status' = ANY(changed_fields)
  AND recorded_date BETWEEN '2026-01-01' AND '2026-01-31'
ORDER BY recorded_at DESC;

-- Uses partition pruning on recorded_date, array operator on changed_fields

Point-in-time reconstruction

Reconstruct what a record looked like at a specific point in time. Essential for debugging and compliance.

-- Get the state of order 12345 as of a specific timestamp
WITH ordered_changes AS (
    SELECT
        new_values,
        recorded_at,
        ROW_NUMBER() OVER (ORDER BY recorded_at DESC) as rn
    FROM audit_log
    WHERE entity_type = 'orders'
      AND entity_id = 12345
      AND recorded_at <= '2026-01-15 14:30:00+00'
      AND action != 'delete'
)
SELECT new_values
FROM ordered_changes
WHERE rn = 1;

-- Returns the complete record state as of that timestamp

Aggregate analysis

Identify patterns: which records change frequently, who's most active, what time of day changes happen.

-- Most frequently modified records
SELECT
    entity_type,
    entity_id,
    COUNT(*) as change_count
FROM audit_log
WHERE recorded_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY entity_type, entity_id
HAVING COUNT(*) > 10
ORDER BY change_count DESC
LIMIT 100;

-- Change volume by hour (useful for capacity planning)
SELECT
    EXTRACT(HOUR FROM recorded_at) as hour,
    COUNT(*) as changes
FROM audit_log
WHERE recorded_date = CURRENT_DATE - 1
GROUP BY EXTRACT(HOUR FROM recorded_at)
ORDER BY hour;

Compliance Requirements

Different regulatory frameworks have specific audit trail requirements. Understanding these upfront shapes implementation decisions.

Regulation Scope Key Requirements
GDPR Personal data of EU residents Record lawful basis for processing. Track consent changes. Log data subject access requests. Document data transfers. Demonstrate "right to be forgotten" execution.
SOX Financial data for public companies Complete audit trail for all financial transactions. Segregation of duties enforcement. No ability to modify audit records. Minimum 7-year retention.
HIPAA Protected health information Log all access to patient records (not just modifications). Track who viewed what and when. 6-year minimum retention. Audit trail for audit trail access.
PCI-DSS Cardholder data Track all access to cardholder data environments. Log authentication attempts. Time-synchronised logs. 1-year readily available, 1-year archived.
ISO 27001 Information security management Demonstrate access control effectiveness. Evidence of security event monitoring. Change management documentation.

Practical implications

These requirements translate into specific technical decisions:

  • Read auditing HIPAA and PCI-DSS require logging access, not just modifications. This means auditing SELECT queries on sensitive tables, typically via application middleware rather than database triggers.
  • Retention policies Different data has different retention requirements. Your audit system needs configurable retention by entity type, with automated archival and deletion.
  • Export capabilities Auditors will request exports. Build structured export (CSV, PDF reports) as a first-class feature, not an afterthought.
  • Time synchronisation PCI-DSS explicitly requires NTP synchronisation. All servers must use the same time source. Log timestamps must be precise and consistent.

Storage and Retention

Audit data grows continuously and indefinitely. A system averaging 50,000 changes per day generates 18 million records per year. At 2KB per record (including indexes), that's approximately 36GB per year of audit data alone. Storage strategy matters.

Tiered storage architecture

Not all audit data needs the same accessibility:

Hot (0-90 days)

Primary database. Full query capability. Immediate access.

Warm (90 days - 2 years)

Separate database or read replica. Slower queries acceptable.

Cold (2-7 years)

Object storage (S3, GCS). Compressed Parquet files. Query via Athena/BigQuery.

Archive (7+ years)

Glacier/Archive storage. Retrieval in hours. Compliance retention only.

Automate tier transitions based on partition age. Monthly partitions make this straightforward:

-- Move partitions older than 90 days to archive tablespace
ALTER TABLE audit_log_2025_09 SET TABLESPACE archive_storage;

-- Export to Parquet and upload to S3 for cold storage
COPY (SELECT * FROM audit_log_2025_09)
TO PROGRAM 'aws s3 cp - s3://audit-archive/2025/09/audit.parquet'
WITH (FORMAT parquet);

Compression strategies

Audit data compresses well because it's highly repetitive (same entity types, similar field patterns). PostgreSQL's TOAST compression handles JSONB automatically. For archived data:

  • Parquet format: Columnar storage with built-in compression. 10-20x size reduction typical for audit data.
  • Partition-level compression: Use pg_squeeze or similar to compress older partitions without downtime.
  • JSONB key compression: Store field name mappings separately and use short keys in the JSONB if storage is critical.

Retention automation

Implement retention as code, not manual process:

-- Scheduled job to enforce retention policy
CREATE OR REPLACE PROCEDURE enforce_audit_retention()
LANGUAGE plpgsql AS $$
DECLARE
    partition_name TEXT;
    partition_date DATE;
BEGIN
    -- Find partitions older than retention period
    FOR partition_name, partition_date IN
        SELECT tablename,
               substring(tablename from 'audit_log_(\d{4}_\d{2})')::date
        FROM pg_tables
        WHERE tablename LIKE 'audit_log_%'
          AND schemaname = 'public'
    LOOP
        -- Archive partitions between 90 days and 2 years
        IF partition_date < CURRENT_DATE - INTERVAL '90 days'
           AND partition_date >= CURRENT_DATE - INTERVAL '2 years' THEN
            EXECUTE format('ALTER TABLE %I SET TABLESPACE archive_storage',
                          partition_name);
        END IF;

        -- Export and drop partitions older than 2 years
        IF partition_date < CURRENT_DATE - INTERVAL '2 years' THEN
            -- Export to S3 first (via external process)
            -- Then detach and drop
            EXECUTE format('ALTER TABLE audit_log DETACH PARTITION %I',
                          partition_name);
            EXECUTE format('DROP TABLE %I', partition_name);
        END IF;
    END LOOP;
END;
$$;

Tamper-Proofing and Integrity

An audit trail that can be modified isn't an audit trail. Immutability is a core requirement. Several techniques ensure integrity:

Database-level immutability

Prevent modifications at the database level:

-- Trigger to prevent updates and deletes
CREATE OR REPLACE FUNCTION audit_immutable_trigger()
RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION 'Audit records cannot be modified or deleted';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_immutable
    BEFORE UPDATE OR DELETE ON audit_log
    FOR EACH ROW EXECUTE FUNCTION audit_immutable_trigger();

-- Remove DELETE and UPDATE privileges
REVOKE UPDATE, DELETE ON audit_log FROM application_user;
GRANT INSERT, SELECT ON audit_log TO application_user;

Hash chaining

Each audit record includes a hash of its contents plus the previous record's hash. Any modification breaks the chain and is detectable.

ALTER TABLE audit_log ADD COLUMN record_hash BYTEA;
ALTER TABLE audit_log ADD COLUMN previous_hash BYTEA;

-- Compute hash on insert
CREATE OR REPLACE FUNCTION compute_audit_hash()
RETURNS TRIGGER AS $$
DECLARE
    prev_hash BYTEA;
BEGIN
    -- Get hash of previous record
    SELECT record_hash INTO prev_hash
    FROM audit_log
    ORDER BY id DESC LIMIT 1;

    NEW.previous_hash := prev_hash;
    NEW.record_hash := sha256(
        COALESCE(prev_hash, ''::bytea) ||
        NEW.recorded_at::text::bytea ||
        NEW.entity_type::bytea ||
        NEW.entity_id::text::bytea ||
        COALESCE(NEW.old_values::text, '')::bytea ||
        COALESCE(NEW.new_values::text, '')::bytea
    );

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_hash_chain
    BEFORE INSERT ON audit_log
    FOR EACH ROW EXECUTE FUNCTION compute_audit_hash();

Periodic verification confirms the chain is intact:

-- Verify hash chain integrity
WITH chain_check AS (
    SELECT
        id,
        record_hash,
        previous_hash,
        LAG(record_hash) OVER (ORDER BY id) as expected_previous
    FROM audit_log
)
SELECT id
FROM chain_check
WHERE previous_hash IS DISTINCT FROM expected_previous
  AND id > 1;  -- First record has no previous

-- Empty result = chain intact

External verification

For highest assurance, periodically export hash digests to an external system:

  • Blockchain anchoring: Write daily/hourly Merkle root to a public blockchain. Provides timestamped proof that records existed and were unmodified.
  • Third-party timestamping: Services like RFC 3161 timestamping authorities provide legally recognised proof of existence.
  • Separate audit of audits: Export hashes to a completely separate system with independent access controls.

Performance at Scale

Audit logging must not become a bottleneck. A few techniques keep audit overhead minimal even under high load.

Asynchronous writes

Don't block the main request waiting for audit writes to complete:

// Queue audit records for background processing
class AuditLogJob implements ShouldQueue
{
    public function __construct(
        private array $auditData
    ) {}

    public function handle(): void
    {
        AuditLog::create($this->auditData);
    }
}

// In the Auditable trait
protected function recordAudit(string $action): void
{
    dispatch(new AuditLogJob([
        'entity_type' => $this->getTable(),
        // ... other fields
    ]));
}

This moves audit writes off the critical path. The trade-off: a small window where recent changes aren't yet visible in the audit log. For most applications, this is acceptable. See our background jobs guide for more on implementing reliable asynchronous processing.

Batch inserts

For high-volume operations, batch audit records and insert in bulk:

// Collect audit records during bulk operation
$auditBatch = [];
foreach ($records as $record) {
    $record->save();
    $auditBatch[] = [
        'entity_type' => $record->getTable(),
        'entity_id' => $record->getKey(),
        // ... other fields
    ];
}

// Single batch insert
AuditLog::insert($auditBatch);

Separate write path

For very high volume systems, route audit writes to a separate database instance:

// config/database.php
'connections' => [
    'mysql' => [...],  // Main application database
    'audit' => [       // Dedicated audit database
        'driver' => 'pgsql',
        'host' => env('AUDIT_DB_HOST'),
        // ...
    ],
],

// AuditLog model
class AuditLog extends Model
{
    protected $connection = 'audit';
}

This isolates audit write load from the operational database. Audit queries don't compete with application queries for resources.

Index maintenance

Large audit tables with many indexes can suffer from index bloat. Schedule regular maintenance:

-- Reindex during low-traffic periods
REINDEX TABLE CONCURRENTLY audit_log;

-- Or use pg_repack for zero-downtime index rebuilds
SELECT pg_repack.repack_table('audit_log');

User Interface Considerations

Audit data is only valuable if users can access it. A few interface patterns make audit trails usable:

  • Activity timelines Show users a chronological timeline of changes to records they care about. Make it readable: "John Smith changed the status from 'Pending' to 'Approved' on 15 Jan 2026 at 14:32." Avoid raw JSON dumps.
  • Diff views For complex changes, show what changed clearly. Side-by-side comparison with additions highlighted in green, removals in red. Critical for text fields and nested objects.
  • Filtering and search Allow filtering by date range, user, type of change, and specific fields. "Show me all price changes in the last month" should be one click.
  • Restore capability Where appropriate, allow users to restore previous values directly from the audit trail. "Revert to this version" is powerful for accidental changes.
  • Export for compliance Auditors need exports. Provide structured exports (CSV, PDF reports) that capture all required information with appropriate date range and filter options.

Security Considerations

The audit trail itself is sensitive data. It reveals who accessed what, when, and potentially contains copies of sensitive field values. Protect it accordingly.

Access controls

Restrict who can read audit logs. Most users should see only audit data for records they can access. Admin access to full audit logs should require additional authentication and be itself audited.

Sensitive data handling

If auditing changes to sensitive fields, consider logging that a change occurred without logging the values, masking values (showing first/last characters only), or encrypting values in the audit log with a separate key.

Audit the audit access

Track who views audit records. This creates accountability around audit access itself. Required by HIPAA and recommended by most security frameworks.

GDPR consideration: Audit logs containing personal data are themselves subject to GDPR. You may need to anonymise or delete audit records when processing a "right to erasure" request, while maintaining enough information to demonstrate compliance. This tension requires careful design.


What You Get

A properly implemented audit trail delivers concrete capabilities:

  • Complete history Who changed what, when, from what value, to what value. Every significant change is recorded with full context.
  • Point-in-time reconstruction Answer "what did this record look like on Tuesday at 3pm?" for any record, any time, within seconds.
  • Compliance evidence GDPR, SOX, HIPAA, PCI-DSS. Produce the evidence auditors need, in the format they need, without manual work.
  • Debugging capability Trace how data got into its current state. Find the exact change that introduced a bug. Identify patterns in data issues.
  • Tamper-proof records Hash chains, immutability constraints, and external verification ensure the audit trail itself cannot be quietly modified.
  • Performance at scale Asynchronous writes, partitioned storage, and tiered archival keep the system fast even with billions of audit records.

Questions like "who changed this and when?" always have answers. Disputes resolve with facts. Compliance audits pass without scrambling. Debugging starts with evidence rather than guesswork.


Further Reading

  • Laravel Auditing Package - Drop-in audit logging for Eloquent models with minimal configuration.
  • pg_partman - Automated partition management for large PostgreSQL audit tables.
  • ICO GDPR Guidance - UK regulator guidance on data protection and audit trail requirements.

Build Your Audit Trail

We implement audit trails that capture every significant change in your system. Structured schemas, efficient storage, tamper-proofing, and queryable history. Compliance satisfied, debugging enabled, accountability built in.

Let's talk about audit trails →
Graphic Swish