php 147 lines · 4 tabs

Recording an Audit Trail of Model Changes with a Laravel Observer

Shared by codesnips Jul 2026
4 tabs
<?php

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->string('event', 32);
            $table->morphs('auditable');
            $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
            $table->json('old_values')->nullable();
            $table->json('new_values')->nullable();
            $table->string('ip_address', 45)->nullable();
            $table->string('user_agent')->nullable();
            $table->timestamps();

            $table->index(['auditable_type', 'auditable_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('audits');
    }
};
4 files · php Explain with highlit

This snippet shows how a Laravel application records a durable audit trail of Eloquent model changes using an observer and a small reusable trait. The core idea is to keep a history of who changed what and when, without scattering logging calls through controllers and services. Because Eloquent fires lifecycle events (created, updated, deleted), an observer can hook into those events centrally and write an Audit row that captures the diff.

In audits table migration, the schema is designed as an append-only log: auditable_type and auditable_id form a polymorphic reference back to the changed model, event records the lifecycle action, and old_values/new_values are JSON columns holding just the attributes that actually changed. Storing the diff rather than a full snapshot keeps rows compact and makes each entry self-explanatory. The user_id column records the actor, and an index on the polymorphic pair keeps per-record history queries fast.

Auditable trait provides the glue. bootAuditable registers AuditObserver on any model that uses the trait, so wiring is a single use Auditable; line per model. The audits method exposes a morphMany relation so history is queryable directly from the record. auditExclude lets a model hide noisy fields like updated_at or remember_token from the log.

AuditObserver does the real work. On updated, it calls getDirty() to find changed attributes and getOriginal() to recover their prior values, filters out excluded keys, and skips writing entirely when nothing meaningful changed — an important guard, since Eloquent can fire updated even on a no-op save. The writeAudit helper resolves the acting user via Auth::id() and captures request metadata like IP and user agent. Using withoutEvents when persisting the Audit avoids any risk of recursive auditing.

The trade-off is that this approach only captures changes made through Eloquent; raw query-builder updates bypass model events and won't be logged. For most compliance and debugging needs, though, this pattern gives a clean, centralized, low-friction audit trail that travels with each model.


Related snips

Share this code

Here's the card — post it anywhere.

Recording an Audit Trail of Model Changes with a Laravel Observer — share card
Link copied