PHPackages                             soullessthread/laravel-audit-log - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. soullessthread/laravel-audit-log

ActiveLibrary

soullessthread/laravel-audit-log
================================

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

03PHP

Since Jul 15Pushed 1mo agoCompare

[ Source](https://github.com/soullessthread/laravel-audit-log)[ Packagist](https://packagist.org/packages/soullessthread/laravel-audit-log)[ RSS](/packages/soullessthread-laravel-audit-log/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependenciesVersions (1)Used By (0)

Laravel Audit Log
=================

[](#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
------------

[](#requirements)

DependencyVersionPHP8.1+Laravel10, 11, or 12MySQL5.7.8+ (native JSON required)MariaDB10.2+PostgreSQL13+ — or 12 with `CREATE EXTENSION IF NOT EXISTS pgcrypto`SQL Server2016+ (SESSION\_CONTEXT and FOR JSON)---

Installation
------------

[](#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
-----------

[](#quick-start)

### 1. Add the trait to your model

[](#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

[](#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

[](#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
----------------------------------

[](#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

[](#simple-form--log-only-the-two-fk-columns-on-attachdetach)

```
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

[](#extended-form--log-extra-pivot-columns-andor-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

[](#what-gets-logged)

FieldValue`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

[](#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

[](#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
-------------------

[](#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

[](#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
--------------------------

[](#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
----------------------

[](#audit-log-table-schema)

ColumnTypeDescription`id`UUIDGenerated 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`JSONColumn snapshot before the change (null on create)`new_values`JSONColumn snapshot after the change (null on delete)`changed_columns`JSONArray 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`JSONExtra context (e.g. tenant ID for multi-tenant apps)`created_at`TIMESTAMPWhen the change was recorded---

Artisan commands
----------------

[](#artisan-commands)

### `audit:sync`

[](#auditsync)

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`

[](#auditprune)

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)
---------------------------

[](#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
-------------------------------

[](#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

[](#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`)
--------------------------------------------

[](#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`:**

FieldDefault (`false`)Full-snapshot (`true`)`old_values`Only columns that changedAll audited columns`new_values`Only columns that changedAll audited columns`changed_columns`Columns that actually changedSame — still only what changedFires when nothing changed?NoNo — 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
----------------------

[](#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)

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
--------------------

[](#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

[](#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)

[](#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

[](#resolution-order-summary)

ScenarioGuards checkedNo params, `auth_guards` is `[]`Default guard onlyNo 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
-----------------------

[](#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
------------

[](#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
-----------------

[](#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
-------

[](#license)

MIT

###  Health Score

20

—

LowBetter than 12% of packages

Maintenance60

Regular maintenance activity

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/79604066?v=4)[Mohammad Kazem saeedi](/maintainers/soullessthread)[@soullessthread](https://github.com/soullessthread)

---

Top Contributors

[![soullessthread](https://avatars.githubusercontent.com/u/79604066?v=4)](https://github.com/soullessthread "soullessthread (1 commits)")

### Embed Badge

![Health badge](/badges/soullessthread-laravel-audit-log/health.svg)

```
[![Health](https://phpackages.com/badges/soullessthread-laravel-audit-log/health.svg)](https://phpackages.com/packages/soullessthread-laravel-audit-log)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
