laravel-audit-log maintained by soullessthread
Laravel Audit Log
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