In a legacy project, we wanted to record every change made to various models. Storing these “snapshots” allows us to keep a history of how data evolves. However, the existing code wasn’t completed, so we created a simple trait to demonstrate an easy way to implement it without significantly altering the existing codebase.

The Mirror Trait

Here’s the “Mirror” trait, which takes care of saving a snapshot each time the model is saved, updated, or deleted:

<?php

namespace App\Traits;

use App\Models\Mirror as Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;

trait Mirror
{
    public function mirrors(): MorphMany
    {
        return $this->morphMany(Model::class, 'mirrored');
    }

    protected function makeMirror()
    {
        $this->mirrors()->create([
            'lock_version' => $this->lock_version,
            'etag' => $this->etag,
            'data' => $this->toArray(),
            // Optionally, we will store who took the action if available.
            'causer_id' => Auth::user()?->id,
            'causer_type' => Auth::user()?->getMorphClass(),
        ]);
    }

    public static function bootMirror()
    {
        static::saving(function ($model) {
            $model->lock_version = $model->lock_version ? ($model->lock_version + 1) : 1;
            $model->etag = Str::uuid()->toString();
        });

        static::created(function ($model) {
            $model->makeMirror();
        });

        static::updated(function ($model) {
            $model->makeMirror();
        });

        static::deleted(function ($model) {
            $model->makeMirror();
        });
    }
}

The Mirrors Table

In your database, create a table to store these snapshots. Here’s an example schema:

CREATE TABLE IF NOT EXISTS "mirrors" (
    // ...
    "mirrored_type" VARCHAR NOT NULL,
    "mirrored_id" INTEGER NOT NULL,
    "causer_type" VARCHAR,    // optional
    "causer_id" INTEGER,      // optional
    "lock_version" INTEGER NOT NULL,
    "etag" VARCHAR NOT NULL,
    "data" TEXT NOT NULL,     // could also be JSON
    // ...
);

The Mirror Model

The Mirror model itself looks like this:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Mirror extends Model
{
    protected $guarded = [];

    protected $casts = [
        'data' => 'array',
    ];

    public function mirrored(): MorphTo
    {
        return $this->morphTo();
    }
}

Using the Mirror Trait in an Model

For example: we are using Order model. Let’s say we have an orders table:

CREATE TABLE IF NOT EXISTS "orders" (
    // ...
    "lock_version" INTEGER NOT NULL,
    "etag" VARCHAR NOT NULL,
    // ...
);

We then create our Order model and include the Mirror trait:

<?php

namespace App\Models;

use App\Traits\Mirror;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use Mirror;

    protected $guarded = [];
}

Testing It Out

Whenever you create or update an Order, a snapshot will automatically be saved in the mirrors table. Here’s an example:

// Creating a new record
\App\Models\Order::create([
  'total' => 100,
  'items' => [
    ['title' => 'WordPress Hat', 'price' => 25, 'quantity' => 2],
    ['title' => 'WordPress T-Shirt', 'price' => 50, 'quantity' => 1],
  ]
]);

// Updating an existing record
\App\Models\Order::first()->update(['total' => 90]);

Both actions will automatically update lock_version, generate a new etag, and create a record of these changes in mirrors.

Limitations

Note that changes only occur when the model’s save, create, or update methods are invoked. Mass operations bypass these model events:

// This won't create snapshots or update lock_version/etag:
\App\Models\Order::query()->update(['total' => 10]);

// This won't work either:
\App\Models\Order::insert([
  // ...
]);

// But iterating through each record does work:
\App\Models\Order::query()->each(fn ($order) => $order->update(['total' => 10]));

Sample Queries & Output:


Conclusion

This simple approach should work for most applications. Still, feel free to adapt it to your specific needs. By using a polymorphic relationship, automatically saving model snapshots whenever a record is created, updated, or deleted, you can easily track data changes without significantly modifying your existing code.

Leave a Reply