soullessthread / laravel-audit-log
Database-level trigger-based audit logging for Laravel supporting MySQL, MariaDB, PostgreSQL, and SQL Server.
Package info
github.com/soullessthread/laravel-audit-log
pkg:composer/soullessthread/laravel-audit-log
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
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:syncmerges their column lists and event sets, then creates a single set of triggers. - No single PK: Pivot tables typically have no surrogate key.
row_idstores only the FK to the declaring model; both FK values appear inold_values/new_values. - Only
BelongsToManyis supported: Declaring a relation that is not aBelongsToManycausesaudit:syncto 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:
- At dispatch time (inside the HTTP request, where auth is available) the package appends
audit_user_idandaudit_user_typeto the raw job payload. - When the worker picks up the job the package reads those values from the payload and initialises
AuditContextwith them before the job'shandle()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:
- Guards listed in
config('audit-log.auth_guards'), first match wins. - 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_idandaudit_user_typewill both benullin 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
$auditLogAllon the model at all) is equivalent tofalseand 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'] |
web → api (first with user wins) |
Params :api |
api only (config is ignored) |
Params :admin,web |
admin → web (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
pgcryptoextension (CREATE EXTENSION IF NOT EXISTS pgcrypto) sincegen_random_uuid()is not built-in until PostgreSQL 13. - SQL Server below 2016:
SESSION_CONTEXTandFOR JSONare not available; upgrade to SQL Server 2016+.
License
MIT