Model Change Logger — Automatic Audit Trails
Series: Every Laravel Project Should Have These Building Blocks
Part: 13 of 35 Level: Intermediate Prerequisites: Model Traits, Request Logger
What You’ll Learn
- Why audit trails matter in production applications
- How to build a bootable trait that auto-records every model change
- The
model_change_logspolymorphic table design - How to attach request IDs to change logs for full traceability
- Querying change history for a record or a related set of records
Why Audit Trails Matter
A user contacts support: “My order status was changed without my approval.” Without an audit trail, you have no answer. With one, you can say: “It was updated at 14:32 on June 10 by Admin #7, changed from pending to processing.”
An audit trail answers: who changed what, when, and from what value to what value.
In the projects here, every model that processes business data — ServiceRequest, Task, Enrolment, Order, Partner — uses the ModelChangeLogger trait. It’s automatic: you add the trait, and every create/update/delete is recorded.
The Database Table
First, create the model_change_logs table:
// database/migrations/create_model_change_logs_table.php
Schema::create('model_change_logs', function (Blueprint $table): void {
$table->id();
$table->morphs('model'); // model_type + model_id (polymorphic)
$table->string('change_type'); // 'created', 'updated', 'deleted'
$table->json('changes'); // the actual diff
$table->string('request_id')->nullable(); // ties to RequestLog
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('user_type')->nullable(); // which guard (admin, user, etc.)
$table->timestamps();
$table->index(['model_type', 'model_id']); // fast lookup per record
$table->index('request_id'); // fast lookup per request
});
And the ModelChangeLog model:
// app/Models/ModelChangeLog.php
class ModelChangeLog extends Model
{
protected $fillable = [
'model_type', 'model_id', 'change_type',
'changes', 'request_id', 'user_id', 'user_type',
];
protected function casts(): array
{
return [
'changes' => 'array',
];
}
public function model(): MorphTo
{
return $this->morphTo();
}
}
The ChangeLogType Enum
// app/Support/Enum/ChangeLogType.php
enum ChangeLogType: string
{
case Created = 'created';
case Updated = 'updated';
case Deleted = 'deleted';
}
The ModelChangeLogger Trait
// app/Support/Traits/ModelChangeLogger.php
<?php
declare(strict_types=1);
namespace App\Support\Traits;
use App\Http\Middleware\RequestLogger;
use App\Models\ModelChangeLog;
use App\Support\Enum\ChangeLogType;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait ModelChangeLogger
{
public static function bootModelChangeLogger(): void
{
static::created(static function (self $model): void {
$model->logChanges($model->getAttributes(), ChangeLogType::Created);
});
static::updating(static function (self $model): void {
$originalAttributes = $model->getOriginal();
$updatedAttributes = $model->getAttributes();
$changedAttributes = array_diff_assoc($updatedAttributes, $originalAttributes);
// Remove updated_at — it changes on every save and is noise
unset($changedAttributes['updated_at']);
if ($changedAttributes === []) {
return;
}
$changes = [];
foreach ($changedAttributes as $key => $value) {
$changes[$key] = [
'old' => $originalAttributes[$key] ?? null,
'new' => $value,
];
}
$model->logChanges($changes, ChangeLogType::Updated);
});
static::deleting(static function (self $model): void {
$model->logChanges($model->getAttributes(), ChangeLogType::Deleted);
});
}
/**
* @return MorphMany<ModelChangeLog, $this>
*/
public function changeLogs(): MorphMany
{
return $this->morphMany(ModelChangeLog::class, 'model')
->latest();
}
private function logChanges(array $changes, ChangeLogType $type): void
{
[$userId, $userType] = $this->resolveActor();
ModelChangeLog::create([
'model_type' => $this::class,
'model_id' => $this->getKey(),
'change_type' => $type->value,
'changes' => $changes,
'request_id' => request()->header(RequestLogger::X_REQUEST_ID),
'user_id' => $userId,
'user_type' => $userType,
]);
}
/**
* @return array{0: int|null, 1: string|null}
*/
private function resolveActor(): array
{
$guards = ['admin', 'merchandiser', 'employee', 'web'];
foreach ($guards as $guard) {
if (auth($guard)->check()) {
return [auth($guard)->id(), $guard];
}
}
return [null, null];
}
}
Using the Trait
// app/Models/ServiceRequest.php
class ServiceRequest extends Model
{
use ModelChangeLogger;
// Everything else stays the same — no other setup needed
}
That’s it. From this point:
- Every
create()records all initial attributes - Every
save()orupdate()records only the fields that changed, with old and new values - Every
delete()records the final state before deletion
Querying Change History
// Get all changes to a specific record
$serviceRequest->changeLogs;
// Get changes with pagination
$serviceRequest->changeLogs()->paginate(20);
// Get only updates (not creates/deletes)
$serviceRequest->changeLogs()
->where('change_type', 'updated')
->get();
// Find who made a specific change
$log = $serviceRequest->changeLogs()->first();
echo "Changed by {$log->user_type} #{$log->user_id}";
echo "at {$log->created_at}";
echo "Changes: " . json_encode($log->changes);
Example output for a status change:
{
"status": {
"old": "pending",
"new": "processing"
},
"updated_by": {
"old": null,
"new": 7
}
}
Loading Change Logs for Related Models
When you want to show a full timeline — including changes to child records — you can query across related models:
// In dp-sampling's ModelChangeLogger trait
public function loadChangeLogsForRelatedModels(array $relatedModelNames = []): Collection
{
$query = ModelChangeLog::query()
->where('model_type', $this::class)
->where('model_id', $this->getKey());
foreach ($relatedModelNames as $relation) {
$this->loadMissing($relation);
foreach ($this->{$relation} as $relatedModel) {
$query->orWhere(function ($q) use ($relatedModel): void {
$q->where('model_type', $relatedModel::class)
->where('model_id', $relatedModel->getKey());
});
}
}
return $query->latest()->get();
}
Usage:
$allChanges = $serviceRequest->loadChangeLogsForRelatedModels(['tasks', 'items']);
This gives you a chronological timeline of every change to the service request and all its tasks and items.
Connecting to the Request Logger
Notice request()->header(RequestLogger::X_REQUEST_ID) in the trait. When the RequestLogger middleware assigns a X-Request-IDto each request, every model change made during that request carries the same ID.
This means you can trace: “What HTTP request caused these changes?” Or: “What changes did this specific HTTP request make?”
// Find all model changes from a specific HTTP request
ModelChangeLog::where('request_id', '550123')->get();
// Cross-reference with the RequestLog record
$requestLog = RequestLog::where('request_id', '550123')->first();
// → shows: URL, method, user, duration, response status
This is full-stack traceability: request → model changes. No external tools required.
Key Takeaways
- The
ModelChangeLoggertrait is bootable — add it to a model and it logs automatically. - The
model_change_logstable is polymorphic — one table for all models. - On
updating, diffgetOriginal()vsgetAttributes()and store only the changed fields with old/new values. - Attach the
X-Request-IDheader fromRequestLoggerto tie model changes to HTTP requests. - Resolve the actor across all guards (
admin,merchandiser,web, etc.) and store which guard and ID made the change. - Skip
updated_atin the diff — it changes on every save and adds noise.
Tips and Gotchas
⚠️ Warning: Always
unset($changedAttributes['updated_at'])from the diff before logging.updated_atchanges on every save — logging it generates noise and obscures real changes. Similarly filter out any automatically-managed timestamp columns.
💡 Tip: The
request_idin the change log ties a model change to the HTTP request that caused it. Cross-referencemodel_change_logs.request_idwithrequest_logs.request_idto see exactly what API call triggered a given change, including the user, IP, and timing.
🔥 Expert Note: Don’t log password hashes, tokens, or other sensitive columns. Add a
$auditExclude = ['password', 'remember_token', 'api_token']property to models and check it in the trait before recording changes.
Further Reading
- Laravel Docs: Eloquent Events
- Laravel Docs: Polymorphic Relationships
- Owen-it/laravel-auditing — a full-featured package if you need more than a custom trait
One Comment