Building an Audit Trail with Eloquent Observers
Sooner or later someone asks who changed a record and when. If you have not planned for it, the answer is an uncomfortable shrug. Eloquent observers give you a clean place to hook into a model's lifecycle, and in this tutorial we use them to build an audit trail for PHP Architect that logs every create, update, and delete without cluttering the models themselves.
Step 1: The Audits Table
Start with a migration for a table to hold the audit records. We store the affected model, the event, the changed data, and the user responsible:
Schema::create('audits', function (Blueprint $table) {
$table->id();
$table->morphs('auditable'); // auditable_type + auditable_id
$table->string('event'); // created, updated, deleted
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->foreignId('user_id')->nullable()->constrained();
$table->timestamp('created_at');
});
Using morphs() means a single audits table can record changes for any model in the app through a polymorphic relationship.
Step 2: The Audit Model
A minimal model with casts for the JSON columns:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Audit extends Model
{
public $timestamps = false;
protected $guarded = [];
protected function casts(): array
{
return [
'old_values' => 'array',
'new_values' => 'array',
];
}
}
Step 3: The Observer
Generate an observer with php artisan make:observer ProductObserver --model=Product. Eloquent calls the matching method on the observer when each lifecycle event fires. We record the relevant values on each:
namespace App\Observers;
use App\Models\Audit;
use App\Models\Product;
use Illuminate\Support\Facades\Auth;
class ProductObserver
{
public function created(Product $product): void
{
$this->record($product, 'created', null, $product->getAttributes());
}
public function updated(Product $product): void
{
$this->record(
$product,
'updated',
$product->getOriginal(),
$product->getChanges()
);
}
public function deleted(Product $product): void
{
$this->record($product, 'deleted', $product->getAttributes(), null);
}
protected function record($model, string $event, ?array $old, ?array $new): void
{
$model->audits()->create([
'event' => $event,
'old_values' => $old,
'new_values' => $new,
'user_id' => Auth::id(),
'created_at' => now(),
]);
}
}
Two Eloquent methods do the heavy lifting here. On update, getChanges() returns only the attributes that actually changed, and getOriginal() gives their previous values, so your audit records the delta rather than the entire row.
Step 4: The Relationship and Registration
Add the polymorphic relationship to the audited model:
public function audits()
{
return $this->morphMany(Audit::class, 'auditable');
}
Then register the observer. The tidiest way is the #[ObservedBy] attribute directly on the model:
use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy(ProductObserver::class)]
class Product extends Model
{
// ...
}
Step 5: Reading the Trail
With that in place, every change flows into the audits table automatically. Querying a record's history is a normal relationship read:
foreach ($product->audits()->latest()->get() as $audit) {
echo "{$audit->event} by user {$audit->user_id}";
}
A Caveat Worth Knowing
Observers fire on Eloquent model events, not on raw query builder writes. A bulk Product::where(...)->update([...]) bypasses the model layer and will not be audited, and the same is true for direct DB calls. If you rely on bulk updates, either route those changes through the model or record them explicitly. For the common case of saving and deleting individual models, observers give you a complete, low-noise audit trail for free.
Further Reading
- Laravel documentation, "Eloquent: Getting Started", Observers (laravel.com/docs/13.x/eloquent#observers)
- Laravel documentation, "Eloquent: Getting Started", Events (laravel.com/docs/13.x/eloquent#events)