A Laravel audit log answers the one question that always comes up after something breaks in production: who changed this record, when, and what was the value before? If your answer is "check the git history" or "ask the team in Slack," you have no audit trail. The wrong fix is to sprinkle Log::info() calls into every controller — that misses model changes made by queued jobs, Artisan commands, and Tinker, and it rots the moment someone adds a new field. The right fix is one reusable Auditable trait that hooks Eloquent model events, captures the old-vs-new diff plus the causer and their IP, and writes it to a single polymorphic audits table. Add the trait to a model and the history follows.
Why not just log changes in the controller?
Controller-level logging assumes every write goes through a controller. It does not. A queue worker reprocessing a failed payment, a php artisan command backfilling data, an observer reacting to another model, a tinker session at 2am during an incident — none of those touch your controllers, and all of them mutate rows you care about. Worse, controller logging is duplicated effort: every create/update/delete action needs its own logging line, and the day someone adds a balance column to the model, they will forget to log it. You end up with an audit trail that is wrong in exactly the cases you built it for.
Eloquent already fires model events — created, updated, deleted — for every persisted change regardless of where it originates. Hook those once in a trait and you capture writes from controllers, jobs, commands, and the REPL with a single source of truth. The diff comes straight from the model's dirty/original attribute state, so you never have to enumerate columns by hand. One caveat to know up front: mass-update and mass-delete queries (Model::where(...)->delete()) bypass model events entirely, so they will not be audited — route deletes you care about through model instances or destroy().
What does the audits table look like?
The schema is deliberately generic. A polymorphic auditable_type/auditable_id pair points at whatever model changed; user_id records the causer; event is the verb; and two JSON columns hold the before and after snapshots. Storing old_values and new_values as JSON means you do not need a column per audited attribute — the same table records changes to a User, an Invoice, or a LoanRepayment without migration churn.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('audits', function (Blueprint $table) {
$table->id();
$table->morphs('auditable'); // auditable_type + auditable_id, indexed together
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('event'); // created | updated | deleted
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->string('ip_address', 45)->nullable(); // 45 chars covers IPv6
$table->string('user_agent')->nullable();
$table->timestamp('created_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('audits');
}
};Two details that bite people: morphs() creates a composite index on (auditable_type, auditable_id), which is exactly what you query by, so do not drop it. And the ip_address column is varchar(45), not the 15 you would size for IPv4 — an IPv6 address with an embedded IPv4 suffix can run the full 45 characters and a tighter column truncates it silently. If your history queries get slow as the table grows, the fix is the same as anywhere else: index for the access pattern. I go deeper on that in my database indexing guide.
How does the Auditable trait hook model events?
The trait does three things. It defines an audits() relation so any model can read its own history; it boots into the model's lifecycle via a bootAuditable method that registers created/updated/deleted listeners; and it builds the diff. For updates, the key is to read getOriginal() (the values as they were loaded from the database) against getChanges() (only the attributes that actually changed on the last save). That gives you a precise old-vs-new pair, not a dump of the whole row.
namespace App\Models\Concerns;
use App\Models\Audit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
trait Auditable
{
public function audits(): MorphMany
{
return $this->morphMany(Audit::class, 'auditable')->latest();
}
public static function bootAuditable(): void
{
static::created(fn (Model $model) => $model->recordAudit('created', [], $model->getAttributes()));
static::updated(function (Model $model) {
$changed = $model->getChanges();
unset($changed['updated_at']); // noise — drop it
if ($changed === []) {
return;
}
$old = array_intersect_key($model->getOriginal(), $changed);
$model->recordAudit('updated', $old, $changed);
});
static::deleted(fn (Model $model) => $model->recordAudit('deleted', $model->getOriginal(), []));
}
protected function recordAudit(string $event, array $old, array $new): void
{
$hidden = $this->getHidden(); // never log password, remember_token, etc.
$this->audits()->create([
'user_id' => Auth::id(),
'event' => $event,
'old_values' => array_diff_key($old, array_flip($hidden)),
'new_values' => array_diff_key($new, array_flip($hidden)),
'ip_address' => Request::ip(),
'user_agent' => substr((string) Request::userAgent(), 0, 255),
'created_at' => now(),
]);
}
}Note the bootAuditable naming. Eloquent lets a trait register its own lifecycle hooks through a bootTraitName method — here bootAuditable — and the framework calls it automatically when the model boots, so you do not touch the model's own boot(). The array_diff_key against getHidden() is the line that keeps you out of trouble: it strips password and remember_token out of both snapshots so you are not writing credentials into an audit table that the support team can read. If you also keep a custom $auditExclude list per model, diff against that too.
Wiring it into a model and capturing the causer
Adding history to a model is now one line — use the trait. The Audit model itself needs the JSON columns cast to arrays and a belongsTo back to the user so you can render the causer's name.
use App\Models\Concerns\Auditable;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
use Auditable; // every create/update/delete is now recorded
}
// app/Models/Audit.php
class Audit extends Model
{
public $timestamps = false; // we set created_at manually
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
];
public function auditable() { return $this->morphTo(); }
public function user() { return $this->belongsTo(\App\Models\User::class); }
}One gotcha with the causer: in queued jobs and Artisan commands there is no authenticated user, so Auth::id() returns null. That is correct, not a bug — a system-initiated change genuinely has no human causer, and a nullable user_id records exactly that. If a job runs on behalf of a user, pass that user id into the job's payload and set it explicitly rather than guessing.
How do I query and render the history timeline?
Because audits() is a relation on the model, reading a record's full history is trivial — eager-load the causer to avoid an N+1 query, then iterate. Each audit row already carries the event verb, the before/after JSON, the user, the IP, and the timestamp, so a timeline view is a straight loop.
$audits = $invoice->audits()->with('user')->paginate(25);
// In a Blade view — render a per-field old -> new diff
foreach ($audits as $audit) {
$who = $audit->user?->name ?? 'System';
foreach ($audit->new_values as $field => $newValue) {
$oldValue = $audit->old_values[$field] ?? null;
echo "{$who} changed {$field} from \"{$oldValue}\" to \"{$newValue}\" "
. "at {$audit->created_at} from {$audit->ip_address}";
}
}A few things this gives you for free in a real UI:
- A per-field diff — because old_values and new_values share keys, you render "status: pending -> paid" rather than dumping two JSON blobs at the reviewer.
- Attribution that survives user deletion — the nullOnDelete() on user_id means the audit row stays even after the causer's account is gone, it just shows as System.
- Distinct create/update/delete events — a deleted row keeps its final state in old_values, so you can still see what was destroyed and by whom.
- Cheap filtering — querying audits where event = 'deleted' and user_id = X surfaces exactly who removed records in an incident window.
If you audit soft-deletable models, decide deliberately whether a soft delete should record a deleted event or an updated one on the deleted_at column — the two tell very different stories to whoever reads the trail. Eloquent fires the deleted event on a soft delete, so by default it lands as a deleted entry; pick the framing that matches how your team reads the history.
How do I keep the audits table from growing forever?
An audit table only grows, and on a busy app it grows fast. Decide a retention window — say 365 days, or whatever your compliance requirement dictates — and prune on a schedule. chunkById keeps memory flat and, unlike chunk(), does not skip rows when you delete mid-iteration, because it pages by primary key rather than by offset.
use App\Models\Audit;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
Audit::where('created_at', '<', now()->subDays(365))
->chunkById(1000, fn ($audits) => $audits->each->delete());
})->daily()->name('prune-audits')->onOneServer();The onOneServer() call matters if you run more than one app server — without it, every box runs the prune simultaneously and you get redundant, competing deletes. Note that onOneServer() needs a shared cache lock (Redis, Memcached, or a database cache store), not the default array driver. For the prune to actually fire you need the scheduler's once-a-minute cron entry installed on the box, and for very large tables you may prefer Eloquent's built-in Prunable trait or a queued cleanup job over an inline closure so a slow delete does not stall the scheduler.
An audit log nobody prunes becomes a liability; an audit log that logs passwords becomes a breach. Build both guards in on day one.
Should I just use spatie/laravel-activitylog instead?
Often, yes. spatie/laravel-activitylog is mature, well-tested, and gives you a LogsActivity trait, configurable per-attribute logging via logOnly() and logExcept(), causer and subject resolution, and a clean fluent API out of the box. If your needs are standard model-change tracking, install it and move on — do not rebuild what a maintained package already does well. Rolling your own earns its keep only when you need a shape the package does not give you cheaply: a custom column layout your reporting depends on, integration with an existing internal events table, or — the one that usually decides it — tenant scoping.
On a multi-tenant SaaS, every audit row needs a tenant_id so one customer can never read another's history, and that constraint usually wants to live in a global scope and a foreign key, not bolted on after the fact. When the audit table is itself part of your tenancy boundary, owning the trait end to end is worth it — I cover the resolver and scoping mechanics in Laravel multi-tenancy from scratch, and an audit log is exactly the kind of cross-cutting concern you want enforced at that layer. An audit trail is also a security control, so it belongs on your Laravel security checklist alongside the other access-integrity measures.
The whole system is one migration, one trait, and one model — and once the trait is on a model, every write through a model instance is recorded with the causer, the IP, and a precise field-level diff, no per-controller effort and nothing to remember. Strip hidden attributes so you never log secrets, route bulk operations through model instances so they are not silently skipped, prune on a schedule so the table stays bounded, and add tenant scoping the moment more than one customer shares the database. Do that and the next time someone asks "who changed this and what was it before," the answer is a query, not a Slack thread.

