I recently found myself on a little bit of a "pickle": I needed to track every data change in some of the models in the application I was working on. My first thought was to get some Laravel package that handles this automatically. Simple, right? Well, not really... Due to some technical reasons, using Composer to install packages wasn’t an option.
So I thought, why not just build it myself?😀
And that's exactly what I did. In this post, I'll walk you through how to create a simple, reusable trait in Laravel to automatically log model changes in your DB.
1. Setting Up the Migration and Model
First, we need a place to store our logs. "Firstly" first, we need to have our migration:
Schema::create('model_activity_logs', function (Blueprint $table) {
$table->id();
$table->morphs('registrable'); // Creates registrable_id and registrable_type
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); // If user is deleted, don't delete our log
$table->string('event', 32); // e.g., created, updated, deleted
$table->json('new_values')->nullable();
$table->json('old_values')->nullable();
$table->timestamps();
});
Besides our "run of the mill" attributes (id, timestamp), there's some other attributes that will help our case. Let's break down the key columns:
morphs('registrable'): Here we'll be using polymorphism magic. It creates
registrable_id and registrable_type. This allows us to store history for any kind of
models we want.
user_id: Stores the ID of the user who performed the action. It's nullable in case changes are made directly into our DB, or cases where an action happens without needing authentication.
event: Simple string to record what happened (In this case, we'll be recording the events as created,
updated, and deleted).
new_values: A JSON column to store the attributes after the change.*
old_values: A JSON column to store the attributes before they were changed.*
json column type is not supported on older versions of MySQL/MariaDB. If
you're using an older database, you can change the column type to text.
Now, for our model:
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ModelActivityLog extends Model
{
protected $fillable = [
'registrable_type',
'registrable_id',
'user_id',
'event',
'new_values',
'old_values',
];
protected $casts = [
'new_values' => 'array',
'old_values' => 'array',
];
public function registrable()
{
return $this->morphTo();
}
public function user()
{
return $this->belongsTo(User::class);
}
}
This is a simple model that sets the fillable attributes, casts our JSON columns to arrays, and sets up the polymorphic relationship.
2. Creating our Logger Trait
Now with all set, we'll need to create a trait that can be easily used into any models we want to track.
Create the LogsModelChanges trait. Then let's start by adding its first simple function:
namespace App\Traits;
use App\Models\ModelActivityLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
trait LogsModelChanges
{
// Hello world :)
}
Inside this trait, we'll add a few helper methods and the main boot method that hooks into the Eloquent events.
The bootLogsModelChanges method relies on a few helpers. Let's add them to the trait.
protected function logActivity(string $event, array $oldValues, array $newValues): void
{
ModelActivityLog::create([
'registrable_type' => static::class,
'registrable_id' => $this->getKey(),
'user_id' => Auth::id(),
'event' => $event,
'old_values' => empty($oldValues) ? null : $oldValues,
'new_values' => empty($newValues) ? null : $newValues,
]);
}
This one is pretty simple and self-explanatory, this method is going to be responsible for taking 3 parameters and adding to our database: the event, the old and new values. Nothing out of the ordinary here.
protected function prepareAttributesForLog(array $attributes): array
{
$ignored = $this->getIgnoredAttributes();
$sanitized = [];
foreach ($attributes as $key => $value) {
if (in_array($key, $ignored, true)) {
continue;
}
$sanitized[$key] = $this->normalizeValue($value);
}
return $sanitized;
}
This one will help us filter out fields we decided to ignore, and returns our data in clean JSON fields.
protected function getIgnoredAttributes(): array
{
// Check if the model has a specific property to ignore attributes
if (property_exists($this, 'logIgnoreAttributes')) {
return (array) $this->logIgnoreAttributes;
}
// Default attributes to ignore
return [
'created_at',
'updated_at',
'deleted_at',
];
}
This function is going to be responsible at what attributes we should ignore when recording our logs. For example, we'll ignore by default the timestamps and soft delete attributes Laravel provide us. I'll go into a little more detail later at how we can customize this by adding other attributes.
protected function normalizeValue($value)
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_array($value)) {
return array_map(fn ($item) => $this->normalizeValue($item), $value);
}
if (is_object($value)) {
if (method_exists($value, 'toArray')) {
return $this->normalizeValue($value->toArray());
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
return json_decode(json_encode($value), true);
}
return $value;
}
To ensure data is stored consistently as JSON, we'll be using of a recursive function to help us treat our data.
And last, but not least:
public static function bootLogsModelChanges(): void
{
static::created(function (Model $model) {
$model->logActivity(
'created',
[], // No old values on creation
$model->prepareAttributesForLog($model->getAttributes())
);
});
static::updated(function (Model $model) {
$changes = $model->prepareAttributesForLog($model->getChanges());
if (empty($changes)) {
return;
}
$original = [];
foreach (array_keys($changes) as $attribute) {
$original[$attribute] = $model->getOriginal($attribute);
}
$model->logActivity(
'updated',
$model->prepareAttributesForLog($original),
$changes
);
});
static::deleted(function (Model $model) {
$model->logActivity(
'deleted',
$model->prepareAttributesForLog($model->getAttributes()),
[] // No new values on deletion
);
});
}
This is the fuction that will boot everything up, and using the static methods from Eloquent, it will act accordingly to the action being done (in this case: created, updated and deleted).
3. Using the Trait in our Models
With the trait complete, using it is pretty simple. Just use it in any Eloquent model you want
to track.
namespace App\Models;
use App\Traits\LogsModelChanges;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use LogsModelChanges; // Here's our boy :)
protected $fillable = ['lorem', 'ipsum', 'example'];
}
Now, any time a Product is created, updated, or deleted, a record will automatically be added to
your model_activity_logs table.
But what if you want to ignore more fields? You can easily customize
this on any model by adding a $logIgnoreAttributes property:
class Product extends Model
{
use LogsModelChanges;
protected $fillable = ['lorem', 'ipsum', 'example'];
// Add any specific attributes to ignore when logging
protected array $logIgnoreAttributes = [
'sensitive_data',
];
}
The trait will automatically merge these with the default ignored attributes.
And that's that! A basic, self-contained, and dependency-free solution for logging model changes in Laravel. By using the power of traits and Eloquent's built-in methods, we can create a powerful (and yet simple) audit trail that is easy to implement in your entire application. 😁
See you next mission!