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