Model Change Logger — Automatic Audit Trails

Model Change Logger — Automatic Audit Trails

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 13 of 35 Level: Intermediate Prerequisites: Model TraitsRequest 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_logs polymorphic 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 — ServiceRequestTaskEnrolmentOrderPartner — 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() or update() 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 ModelChangeLogger trait is bootable — add it to a model and it logs automatically.
  • The model_change_logs table is polymorphic — one table for all models.
  • On updating, diff getOriginal() vs getAttributes() and store only the changed fields with old/new values.
  • Attach the X-Request-ID header from RequestLogger to tie model changes to HTTP requests.
  • Resolve the actor across all guards (adminmerchandiserweb, etc.) and store which guard and ID made the change.
  • Skip updated_at in 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_at changes on every save — logging it generates noise and obscures real changes. Similarly filter out any automatically-managed timestamp columns.

💡 Tip: The request_id in the change log ties a model change to the HTTP request that caused it. Cross-reference model_change_logs.request_id with request_logs.request_id to 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


← Observers & Events | Next: Support Namespaces →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment