owen-it/laravel-auditing
Laravel Auditing keeps a history of changes to your Eloquent models using a simple trait. Easily retrieve audited data to track discrepancies, anomalies, and suspect activity, and display detailed model change logs in your Laravel application.
1. Install the right major for your stack first. The README's version matrix is the single most important thing to check, because 13.x and below are end-of-life:
| Package | Laravel | PHP |
|---|---|---|
| 14.x | 11.x – 13.x | ≥ 8.2 |
| 13.x and below | ≤ 11.x | EOL |
composer require owen-it/laravel-auditing
2. Publish and run the migration. Don't write the audits table by hand — the stub already handles the morphs, JSON payload columns, indexes and nullable user columns:
php artisan vendor:publish --provider="OwenIt\Auditing\AuditingServiceProvider" --tag="config"
php artisan vendor:publish --provider="OwenIt\Auditing\AuditingServiceProvider" --tag="migrations"
php artisan migrate
3. Opt a model in. Both the contract and the trait are required — the contract is what the Auditor, drivers and Audit model type-hint against:
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use OwenIt\Auditing\Auditable;
class Invoice extends Model implements AuditableContract
{
use Auditable;
protected $auditExclude = ['internal_notes', 'remember_token'];
protected $auditStrict = true;
protected $auditEvents = ['created', 'updated', 'deleted'];
}
4. First use case — a per-record change history panel. This is what 90% of teams actually ship:
$lastChange = $invoice->audits()->latest()->first();
$lastChange->event; // created | updated | deleted | restored | custom
$lastChange->user; // resolved User model (nullable)
$lastChange->created_at;
$lastChange->getModified(); // ['status' => ['old' => 'draft', 'new' => 'sent']]
Where to look first: config/audit.php (drivers, resolvers, exclude, events, threshold), the Auditable trait, and OwenIt\Auditing\Models\Audit — the latter is where old_values/new_values casting and getModified() live.
Presentation layer via getModified(). Prefer it over hand-diffing old_values/new_values: it returns only the attributes that actually changed, keyed by attribute, with old omitted on created and new omitted on deleted.
@foreach ($invoice->audits()->with('user')->latest()->paginate(20) as $audit)
<tr>
<td>{{ $audit->created_at }}</td>
<td>{{ $audit->user?->name ?? 'system' }}</td>
<td>{{ $audit->event }}</td>
<td>
@foreach ($audit->getModified() as $field => $change)
{{ $field }}: {{ $change['old'] ?? '—' }} → {{ $change['new'] ?? '—' }}
@endforeach
</td>
</tr>
@endforeach
Global activity feed. Query the Audit model directly and eager-load both sides of the morph. Always eager-load user — it's a morphTo and N+1s badly on an admin page:
Audit::with(['user', 'auditable'])
->whereIn('event', ['created', 'deleted'])
->latest()
->paginate(50);
Filter by JSON payload where the DB supports it (MySQL 5.7+/Postgres):
Audit::where('auditable_type', Invoice::class)
->where('new_values->status', 'approved')
->get();
Manual audits for business-significant actions that aren't model writes. Auditor::execute() takes any Auditable model and lets you tag and name the event:
use OwenIt\Auditing\Facades\Auditor;
Auditor::withAuditEvent('approved')
->withTags(['billing', 'compliance'])
->execute($invoice);
Use the same mechanism to cover mass updates that never fire Eloquent events, and to name custom transitions ($model->transitionTo('refunded') before save() when you need the event name on the audit row rather than updated).
Per-model event whitelisting. $auditStrict = true + $auditEvents is the pattern for noisy models. Without strict mode, every Eloquent event the trait listens to produces a row. Leave retrieved out unless you have a very specific read-tracking requirement.
Redaction for PII. $auditExclude only skips storage; $auditInclude/$auditExclude don't help when you need the field but not the raw value. Use redactors/modifiers/encoders instead, and set them per model:
protected $attributeRedactor = \OwenIt\Auditing\Redactors\LeftRedactor::class;
// or globally under 'redactors' in config/audit.php
Custom user resolution for API/tenant apps. The default UserResolver walks the configured guards. If your auth is token/tenant-scoped, ship a resolver implementing OwenIt\Auditing\Contracts\UserResolver and point config('audit.user.resolver') at the FQCN. Combine with morph_prefix + user_type when you have more than one authenticatable type (the multi-user feature from v7).
Custom driver for high-volume tables. Implement OwenIt\Auditing\Contracts\AuditDriver (audit(), prune()) and register under config('audit.drivers'). This is the cleanest route for shipping audits to a separate database, a wide column store, or an append-only log sink without touching model code.
Retention as a scheduled job. Every save is an INSERT; the table grows monotonically. $auditThreshold caps rows per model at write time, and Auditor::prune() handles bulk cleanup:
// App\Console\Kernel or routes/console.php
$schedule->call(fn () => Auditor::prune(Invoice::class, 24))->daily(); // keep 24 months
Queued auditing for write-heavy paths. Set protected $auditQueue = 'audits'; on the model so the insert leaves the request cycle. Make sure the worker has the auditing config cached and the queue connection is durable — this trades immediate visibility for latency.
Mass updates silently produce zero audits. Invoice::where(...)->update([...]), ->delete(), upserts and raw SQL all bypass Eloquent events. This is the #1 source of "why is our history incomplete". Either chunk-and-save() or emit explicit Auditor::execute() calls.
hidden is not excluded. The trait reads model attributes directly, so $hidden fields like password, two_factor_secret and remember_token will be written into old_values/new_values unless you list them in $auditExclude or route them through a redactor. Audit this before your first production deploy — the JSON columns are readable by anyone with DB access.
Only dirty attributes are stored. new_values contains just the changed keys, and old_values only those same keys. Code that assumes a full snapshot row will break; use getModified() and treat absent keys as "unchanged", not "null". deleted is the exception — it stores the full attribute set.
Morph maps are a long-term trap. auditable_type stores whatever the morph alias was at write time. Renaming a model, adding Relation::enforceMorphMap() later, or moving a class namespace orphans every historical row. Decide on a morph map before going live, and treat it as immutable.
Config caching breaks closures. Resolvers and drivers must be FQCNs, not closures — php artisan config:cache will fail or the resolver will be silently ignored. This has been a constraint since 4.0.2 and still bites people.
User is unresolvable in console, queue and scheduler contexts. user_id will be null and the URL/IP resolvers may return null because there's no request. If you queue audits, resolve and stash the actor before dispatching (e.g. pass it explicitly or set it via a scoped resolver) — otherwise your compliance trail loses attribution exactly where it matters.
Disable auditing in seeders, factories and import jobs:
Invoice::disableAuditing();
// ... bulk work ...
Invoice::enableAuditing();
isAuditingEnabled() was tightened in the 14.x line (14.0.3 avoids wiring listeners at all when disabled), so prefer this over Model::withoutEvents(), which also kills your own observers and any downstream side effects.
Watch for observer recursion. Saving an audited model from inside its own observer/saved listener re-enters auditing and can loop or inflate the table. Guard with a flag or disableAuditing() around the nested write.
Transactions vs. the queue. With the database driver the audit row is inserted inside the surrounding transaction, so a rollback correctly discards it. With $auditQueue it isn't — a rolled-back transaction can still leave an audit record behind. Don't mix queued auditing with critical atomic workflows.
Restore semantics changed. 14.0.4 fixed restore swallowing other update events; if you're on an earlier 14.0.x and see missing audits after a soft-delete restore, upgrade rather than patching around it. Note that restore deliberately does not create a spurious updated audit (fixed back in 4.0.4).
Indexing for the queries you'll actually run. The stub gives you the auditable morph index; add your own on created_at (pruning and feeds), user_id (per-user activity), and any event filter you use in admin screens. On older MySQL, url/user_agent as TEXT means you can't index them — don't try.
Debugging a missing audit: check, in order — trait + contract present, event in $auditEvents under $auditStrict, the attribute in $auditExclude, isAuditingEnabled(), whether the write went through the query builder, and whether config('audit.driver') points at a driver you actually registered. Auditor::execute($model) returning null almost always means the model doesn't implement the Auditable contract.
Testing: assert on $model->audits()->count() and ->latest()->first()->getModified(). Because auditing is event-driven, Event::fake() will suppress audits along with everything else — assert the DB state instead of faking events, and remember to enableAuditing() in tearDown() if a test disabled it, since the flag lives on the class.
How can I help you explore Laravel packages today?