L LAB

Audit Trail

An append-only audit log for domain and lifecycle events. It writes a row to audit_logs describing who did what, on which entity, when, with what payload — and rotates old rows out via a scheduled prune.

The module is non-fatal by design: when an audit write fails (DB down, schema mismatch, queue offline) the failure is logged and null is returned. Business code is never interrupted.

Three ways to emit

  1. Helperaudit_log('event.name', $model, ['metadata' => [...]]) for cross-cutting events.
  2. Auditable trait — opt-in per model; auto-captures created, updated, deleted with the attribute diff.
  3. Service directlyapp(AuditLogger::class)->log(...) for the typed API or to pass an explicit user.

Defaults

Moduleenabled
Write modesynchronous (AUDIT_QUEUE=true → queued)
Retention180 days, daily prune at 03:00
Request contextcaptured (IP, UA, X-Request-Id)
Redacted keyspassword, *_token, secret, api_key, …
Allow-listnone (every event recorded)

Configuration

'audit' => [
    'enabled' => env('AUDIT_ENABLED', true),
    'connection' => env('AUDIT_DB_CONNECTION'),   // null = default
    'queue' => env('AUDIT_QUEUE', false),
    'capture_request_context' => env('AUDIT_CAPTURE_REQUEST_CONTEXT', true),
    'redact_keys' => ['password', 'token', 'secret', /* ... */],
    'events_allowlist' => null,   // null = allow all
    'prune' => [
        'enabled' => env('AUDIT_PRUNE_ENABLED', true),
        'days' => env('AUDIT_PRUNE_DAYS', 180),
    ],
],

redact_keys is case-insensitive and recursive, applied to old_values, new_values and metadata before persistence.

Helper usage

audit_log('auth.login', $user, ['metadata' => ['method' => 'password']]);

audit_log('users.email_changed', $user, [
    'old' => ['email' => '[email protected]'],
    'new' => ['email' => '[email protected]'],
]);

// Override the acting user (e.g. system job)
audit_log('billing.invoice_voided', $invoice, [
    'user' => $admin,
    'metadata' => ['reason' => 'refund'],
]);

The acting user is auto-resolved from the request (falling back to the sanctum guard) unless overridden.

Auditable trait

use App\Models\Concerns\Auditable;

class Order extends Model
{
    use Auditable;

    protected array $auditExclude = ['internal_notes'];
}

Event names follow <snake_model>.<verb> (order.created, …). updated only fires when an attribute is actually dirty; old_values holds only changed keys.

Querying

AuditLog::query()->forUser($user->id)->latest()->paginate(20);
AuditLog::query()->forEvent('auth.login')->between(now()->subDay(), now())->get();
AuditLog::query()->with('user')->latest()->paginate(20);

Pruning

php artisan audit:prune                # per config.days
php artisan audit:prune --days=30
php artisan audit:prune --dry-run

Scheduled daily at 03:00. Deletes happen in chunked batches to keep the transaction footprint small. The created_at-only schema is immutable — there is no updated_at.

Exposing to admins

The model, scopes and polymorphic relation are ready; controllers are intentionally out of scope so each project picks its own admin UI and authorisation story. Build an Admin\AuditLogController returning paginated AuditLog::query() results when you need one.