soullessthread/laravel-audit-log

Database-level trigger-based audit logging for Laravel supporting MySQL, MariaDB, PostgreSQL, and SQL Server.

Maintainers

Package info

github.com/soullessthread/laravel-audit-log

pkg:composer/soullessthread/laravel-audit-log

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-07-15 08:45 UTC

This package is auto-updated.

Last update: 2026-07-15 08:52:45 UTC


README

Database-level trigger-based audit logging for Laravel.

Changes are captured entirely inside the database engine — PHP application code does not intercept Eloquent events. This means bulk updates, raw queries, and changes made by other services are all captured automatically.

Supported databases: MySQL 5.7.8+, MariaDB 10.2+, PostgreSQL 13+, SQL Server 2016+

Requirements

Dependency Version
PHP 8.1+
Laravel 10, 11, or 12
MySQL 5.7.8+ (native JSON required)
MariaDB 10.2+
PostgreSQL 13+ — or 12 with CREATE EXTENSION IF NOT EXISTS pgcrypto
SQL Server 2016+ (SESSION_CONTEXT and FOR JSON)

Installation

composer require soullessthread/laravel-audit-log

Publish the config and migration:

php artisan vendor:publish --tag=audit-log-config
php artisan vendor:publish --tag=audit-log-migrations
php artisan migrate

Quick start

1. Add the trait to your model

use soullessthread\AuditLog\Concerns\HasAuditLog;

class Invoice extends Model
{
    use HasAuditLog;

    // Columns to watch ('*' = all non-excluded columns)
    protected array $auditColumns = ['status', 'total', 'paid_at'];

    // Events to capture (all three are the default)
    protected array $auditEvents = ['created', 'updated', 'deleted'];

    // Optional: write to a dedicated table instead of the shared audit_logs
    protected ?string $auditTable = 'invoice_audit_logs';

    // Columns stored as '[REDACTED]' — merged with config sensitive_columns
    protected array $auditSensitive = ['card_last4'];

    // Columns to skip even when $auditColumns is ['*']
    protected array $auditExclude = ['internal_note'];

    // Optional: store the full row snapshot on every UPDATE (see below)
    protected bool $auditLogAll = true;
}

2. Register the middleware

Add SetAuditContext after your authentication middleware so that the authenticated user is available:

// Laravel 11+ (bootstrap/app.php)
->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('web', \soullessthread\AuditLog\Middleware\SetAuditContext::class);
    $middleware->appendToGroup('api', \soullessthread\AuditLog\Middleware\SetAuditContext::class);
})

// Laravel 10 (app/Http/Kernel.php)
protected $middlewareGroups = [
    'web' => [
        // ... Authenticate::class must come before this
        \soullessthread\AuditLog\Middleware\SetAuditContext::class,
    ],
];

3. Sync database triggers

Run this whenever you add or change a HasAuditLog model:

php artisan audit:sync

Options:

--model=App\Models\Invoice   Sync only one model
--connection=mysql           Override DB connection
--dry-run                    Print SQL without executing

Many-to-many (pivot table) logging

audit:sync can also create triggers on pivot tables for BelongsToMany relations. Declare the relation method names in $auditRelations on your model.

Simple form — log only the two FK columns on attach/detach

class User extends Model
{
    use HasAuditLog;

    protected array $auditColumns  = ['name', 'email'];
    protected array $auditRelations = ['roles', 'permissions'];

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }

    public function permissions(): BelongsToMany
    {
        return $this->belongsToMany(Permission::class);
    }
}

Running audit:sync now creates INSERT and DELETE triggers on both role_user and permission_user.

Extended form — log extra pivot columns and/or update events

protected array $auditRelations = [
    'roles',                            // minimal: just FKs, insert + delete only

    'permissions' => [
        // Extra pivot columns to include in the JSON snapshot
        'columns'     => ['expires_at', 'granted_by'],

        // Add 'updated' if the pivot row can be updated (e.g. withPivot timestamps)
        'events'      => ['created', 'deleted', 'updated'],

        // Redact sensitive pivot columns (merged with config sensitive_columns)
        'sensitive'   => ['granted_by'],

        // Optional: write pivot logs to a custom table
        'audit_table' => 'permission_pivot_logs',
    ],
];

What gets logged

Field Value
table_name The pivot table (e.g. role_user)
row_id Value of the FK pointing to this model (e.g. user_id value)
event_type created (attach), deleted (detach), or updated (pivot update)
old_values All audited pivot columns before the change (null on attach)
new_values All audited pivot columns after the change (null on detach)
changed_columns List of columns that actually changed (attach/detach = all columns)

Querying pivot logs

// All role assignments for a user
AuditLog::query()
    ->where('table_name', 'role_user')
    ->where('row_id', $user->id)
    ->latest('created_at')
    ->get();

// Only detachments
AuditLog::query()
    ->where('table_name', 'role_user')
    ->where('event_type', 'deleted')
    ->get();

Notes

  • Trigger naming: Pivot triggers use the suffix _auditlog_piv_{event} to avoid conflicts with regular model triggers on the same table.
  • Deduplication: If two models declare the same pivot table in their $auditRelations, audit:sync merges their column lists and event sets, then creates a single set of triggers.
  • No single PK: Pivot tables typically have no surrogate key. row_id stores only the FK to the declaring model; both FK values appear in old_values/new_values.
  • Only BelongsToMany is supported: Declaring a relation that is not a BelongsToMany causes audit:sync to print a warning and skip it.

Querying audit logs

use soullessthread\AuditLog\Facades\AuditLog;

// All changes to a specific model instance
AuditLog::for($invoice)->latest('created_at')->get();

// Filter by event type
AuditLog::for($invoice)->ofEvent('updated')->get();

// Everything that happened in the same request / command / job
AuditLog::session($sessionUuid)->get();

// All changes made by a specific user
AuditLog::byUser(auth()->user())->get();

// All changes that came from a specific route
AuditLog::fromSource('route:invoices.update')->get();

// Combine scopes freely (returns an Eloquent Builder)
AuditLog::for($invoice)
    ->ofEvent('updated')
    ->where('user_id', $userId)
    ->latest('created_at')
    ->paginate(20);

Column-level diff

$entry = AuditLog::for($invoice)->ofEvent('updated')->latest('created_at')->first();

$diff = $entry->getDiff();
// ['status' => ['old' => 'pending', 'new' => 'paid'], 'total' => ['old' => 100, 'new' => 150]]

$entry->hasColumnChanged('status'); // true

Restoring a previous state

$entry = AuditLog::for($invoice)->ofEvent('updated')->latest('created_at')->first();

AuditLog::restoreTo($invoice, $entry); // re-applies $entry->old_values via $invoice->update()

The restore fires another UPDATE, which produces its own audit record — creating a full trail of undo operations.

Audit log table schema

Column Type Description
id UUID Generated by the trigger
table_name VARCHAR(100) Source table
row_id VARCHAR(36) PK of the changed row (works with int and UUID PKs)
event_type ENUM created, updated, or deleted
old_values JSON Column snapshot before the change (null on create)
new_values JSON Column snapshot after the change (null on delete)
changed_columns JSON Array of columns that actually differed
user_id VARCHAR(36) Authenticated user ID
user_type VARCHAR(100) Auth guard model class (supports multiple guards)
session_uuid CHAR(36) Groups all changes from one request / command / job
source VARCHAR(255) route:name, command:name, or job:ClassName
tags JSON Extra context (e.g. tenant ID for multi-tenant apps)
created_at TIMESTAMP When the change was recorded

Artisan commands

audit:sync

Generate or regenerate triggers for all HasAuditLog models.

php artisan audit:sync
php artisan audit:sync --model="App\Models\Invoice"
php artisan audit:sync --dry-run
php artisan audit:sync --connection=secondary

audit:prune

Delete audit records older than the configured retention window.

php artisan audit:prune
php artisan audit:prune --days=30
php artisan audit:prune --table=invoice_audit_logs
php artisan audit:prune --all-tables=audit_logs,invoice_audit_logs

Schedule it in your console kernel:

$schedule->command('audit:prune')->daily();

Multi-tenant support (tags)

Inject per-tenant context early in the request lifecycle — before any queries fire:

// config/audit-log.php
'context_extras' => fn (\Illuminate\Http\Request $request) => [
    'tenant_id' => tenant()->id,
],

Or manually at any point during a request:

AuditLog::tag(['tenant_id' => $tenantId]);
// This re-pushes context to the DB connection immediately

Console commands and queue jobs

Audit context is injected automatically via event listeners registered in the service provider:

  • Artisan commands → source is command:{name}
  • Queue jobs → source is job:{ClassName}

No additional code is needed in your job or command classes.

Authenticated user in queue jobs

Queue workers run in a separate process where auth()->user() is always null. The package solves this automatically using Laravel's Queue::createPayloadUsing() hook:

  1. At dispatch time (inside the HTTP request, where auth is available) the package appends audit_user_id and audit_user_type to the raw job payload.
  2. When the worker picks up the job the package reads those values from the payload and initialises AuditContext with them before the job's handle() method runs.

This means every database write made inside a queued job is attributed to the user who dispatched it — no changes to your job classes required.

The guard resolution follows the same priority as the SetAuditContext middleware:

  1. Guards listed in config('audit-log.auth_guards'), first match wins.
  2. Laravel's default guard as a final fallback.

Note: If a job is dispatched from a console command or from another job (where there is no authenticated user), audit_user_id and audit_user_type will both be null in the log — this is expected and handled gracefully.

To manually override context inside a job (e.g. for a scheduled command that acts on behalf of a specific user):

public function handle(): void
{
    AuditLog::context()->setUser((string) $this->userId, 'App\Models\User');
    app(\soullessthread\AuditLog\Trigger\TriggerManager::class)
        ->pushContext(AuditLog::context()->toArray());

    // ... rest of job
}

Full-row snapshots on update (auditLogAll)

By default the UPDATE trigger is selective: it only stores the columns that actually changed in old_values and new_values, keeping audit rows compact. When you need the full row context for every update — for example, to reconstruct the exact state at any point in time without cross-referencing other tables — enable $auditLogAll:

class Order extends Model
{
    use HasAuditLog;

    protected array $auditColumns = ['status', 'total', 'paid_at', 'notes'];

    // Store ALL audited columns in old_values/new_values on every UPDATE.
    protected bool $auditLogAll = true;
}

Behaviour with auditLogAll = true:

Field Default (false) Full-snapshot (true)
old_values Only columns that changed All audited columns
new_values Only columns that changed All audited columns
changed_columns Columns that actually changed Same — still only what changed
Fires when nothing changed? No No — a no-op UPDATE still produces no log row

The property is optional. Omitting it entirely (or not defining $auditLogAll on the model at all) is equivalent to false and keeps the default compact behaviour.

After adding or changing this property, re-run php artisan audit:sync to regenerate the trigger.

Per-model audit tables

When a model declares protected ?string $auditTable = 'invoice_audit_logs', the generated trigger writes to that table instead of the shared audit_logs. You must create the custom table manually:

# Copy and rename the published migration, then run:
php artisan migrate

The schema is identical to the default audit_logs table.

Soft deletes

Soft deletes are UPDATE statements (setting deleted_at). The update trigger will capture the change if deleted_at is included in the audited columns (it is included by default when using '*', unless added to global_exclude_columns).

To find soft-deleted records in your audit log:

AuditLog::for($model)
    ->ofEvent('updated')
    ->whereJsonContains('changed_columns', 'deleted_at')
    ->get();

Multiple auth guards

By default the middleware checks only Laravel's default guard. When your application uses several guards (e.g. web, api, a separate admin guard) you can tell the package which ones to check and in what order.

Global config

Set auth_guards in config/audit-log.php to an ordered array. The middleware iterates the list and stops at the first guard that has an authenticated user:

'auth_guards' => ['web', 'api'],

Per-route override (middleware parameter)

Pass guard names directly to the middleware using the standard Laravel colon syntax. Route-level params take full priority over auth_guards in config:

// Laravel 11+ (bootstrap/app.php)
->withMiddleware(function (Middleware $middleware) {
    // API routes only check the 'api' guard
    $middleware->appendToGroup('api',
        \soullessthread\AuditLog\Middleware\SetAuditContext::class . ':api'
    );

    // Admin routes check 'admin' first, then 'web' as fallback
    $middleware->appendToGroup('web',
        \soullessthread\AuditLog\Middleware\SetAuditContext::class . ':admin,web'
    );
})
// In a route group
Route::middleware(['auth:admin', SetAuditContext::class . ':admin'])->group(function () {
    // audit logs will carry the admin's user_id and user_type
});

Resolution order summary

Scenario Guards checked
No params, auth_guards is [] Default guard only
No params, auth_guards is ['web','api'] webapi (first with user wins)
Params :api api only (config is ignored)
Params :admin,web adminweb (config is ignored)

Configuration reference

// config/audit-log.php
return [
    'default_table'          => 'audit_logs',
    'connection'             => env('DB_CONNECTION', 'mysql'),
    'model_paths'            => [app_path('Models')],
    'global_exclude_columns' => ['created_at', 'updated_at', 'deleted_at', 'remember_token'],
    'sensitive_columns'      => ['password', 'password_hash', 'secret', 'token', 'api_key', 'api_secret'],
    'retention_days'         => 365,

    // Guards checked in order; first authenticated user wins.
    // Override per-route with the middleware parameter (see below).
    'auth_guards'            => [],   // [] = default guard only

    'context_extras'         => null,
];

How it works

HTTP Request
  └─ SetAuditContext middleware
       └─ Generates session UUID
       └─ Reads auth user
       └─ Calls SourceResolver (route / command / job)
       └─ Runs SET @audit_* = ... (MySQL) / set_config() (PG) / sp_set_session_context() (SQL Server)

Model::update() / DB::table()->update()
  └─ Fires DB-level AFTER UPDATE trigger
       └─ Compares OLD vs NEW for each audited column
       └─ If any column changed:
            └─ Reads @audit_user_id, @audit_session_uuid, @audit_source, ...
            └─ Inserts one row into audit_logs

Because the trigger runs inside the same database transaction as the original statement, if the statement rolls back, the audit record rolls back too — ensuring perfect consistency.

Known limitations

  • Composite primary keys are not supported. Only single-column PKs.
  • PostgreSQL 12 and below: install pgcrypto extension (CREATE EXTENSION IF NOT EXISTS pgcrypto) since gen_random_uuid() is not built-in until PostgreSQL 13.
  • SQL Server below 2016: SESSION_CONTEXT and FOR JSON are not available; upgrade to SQL Server 2016+.

License

MIT