Automatic Model Versioning in Laravel: Building an Audit Trail with the Versionable Trait
Introduction
Maintaining a complete audit trail of model changes is crucial for many applications. Whether you need to track who changed what and when, implement undo/redo functionality, or comply with regulatory requirements, automatic versioning provides a robust foundation.
This guide demonstrates how to build a Versionable trait that automatically captures model versions on every create and update operation. The trait uses Laravel's model events, polymorphic relationships, and intelligent change detection to create a zero-configuration versioning system.
Prerequisites
- Laravel 10.x or higher (works with earlier versions with minor adjustments)
- PHP 8.1 or higher
- Basic understanding of Eloquent models and traits
- Familiarity with Laravel model events
The Architecture
The versioning system consists of three main components:
- Versionable Trait - Automatically hooks into model events
- ModelVersion Model - Stores version snapshots using polymorphic relationships
- TextUtil - Intelligent change detection (handles strings, arrays, JSON)
Database Schema
First, create a migration for the model_versions table:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('model_versions', function (Blueprint $table) {
$table->id();
$table->morphs('versionable');
$table->unsignedInteger('version_number');
$table->json('data');
$table->foreignId('created_by')->nullable()->constrained('users');
$table->timestamps();
$table->index(['versionable_type', 'versionable_id', 'version_number']);
});
}
public function down(): void
{
Schema::dropIfExists('model_versions');
}
};Building the Versionable Trait
Boot Method and Event Hooks
The trait uses Laravel's boot{TraitName} convention to automatically register event listeners:
<?php
namespace App\Traits;
use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait Versionable
{
/**
* Automatically attach versioning to the model's events.
*/
public static function bootVersionable(): void
{
static::updating(function ($model) {
if (method_exists($model, 'createVersion')) {
$model->createVersion();
}
});
static::created(function ($model) {
// Capture initial baseline version on create
if (method_exists($model, 'createVersion')) {
$model->createVersion(true);
}
});
}
}Laravel Boot Convention
Laravel automatically calls boot{TraitName}() when a model using the trait is booted. This provides a clean way to register event listeners without explicit configuration.
Polymorphic Relationship
Define the relationship to version records:
/**
* Get all versions for this model.
*/
public function versions(): MorphMany
{
return $this->morphMany(ModelVersion::class, 'versionable')
->orderBy('version_number', 'desc');
}Customizable Versioning Behavior
Provide hooks for models to customize which attributes get versioned:
/**
* Get the attributes that should be versioned.
* Override in your model to customize what gets versioned.
*/
public function getVersionableAttributes(): array
{
return $this->attributesToArray();
}
/**
* Get the fields that should trigger versioning when changed.
* Override in your model to specify which fields to track.
*/
public function getVersionableFields(): array
{
// By default, track all fillable fields
return $this->fillable;
}
/**
* Get the fields to ignore when checking for significant changes.
* Override in your model to specify ignored fields.
*/
public function getIgnoredFields(): array
{
return [];
}
/**
* Get the field prefixes to ignore when checking for significant changes.
* Override in your model to specify ignored prefixes.
*/
public function getIgnoredPrefixes(): array
{
return [];
}Intelligent Change Detection
The trait uses a utility method to determine if changes are significant enough to warrant a new version:
/**
* Check if a version should be created based on current changes.
*/
public function shouldCreateVersion(): bool
{
$versionableFields = $this->getVersionableFields();
foreach ($versionableFields as $field) {
if ($this->isDirty($field)) {
$original = $this->getOriginal($field);
$current = $this->getAttribute($field);
// Filter ignored fields to get context-aware paths
$ignoredFields = $this->getIgnoredFields();
$contextualIgnoredFields = [];
foreach ($ignoredFields as $ignoredField) {
if (str_starts_with($ignoredField, "{$field}.")) {
// Remove the field prefix to get relative path
$contextualIgnoredFields[] = substr($ignoredField, strlen($field) + 1);
} elseif (!str_contains($ignoredField, '.')) {
// Keep non-dotted fields as is
$contextualIgnoredFields[] = $ignoredField;
}
}
// Use TextUtil to check for significant changes
if (TextUtil::hasSignificantChanges(
$original,
$current,
$contextualIgnoredFields,
$this->getIgnoredPrefixes()
)) {
return true;
}
}
}
return false;
}Change Detection
The TextUtil::hasSignificantChanges() method handles various data types (strings, arrays, JSON) and can ignore specific fields or field prefixes. This prevents creating versions for insignificant changes like timestamp updates or computed fields.
Creating Versions
The core versioning logic:
/**
* Create a new version of this model.
*/
public function createVersion(bool $force = false): ?ModelVersion
{
try {
// Only create version if there are significant changes
if (!$force && !$this->shouldCreateVersion()) {
return null;
}
$nextVersionNumber = ($this->versions()->max('version_number') ?? 0) + 1;
// When called during updating, capture the original state
$dataToVersion = $this->exists && $this->isDirty()
? array_merge($this->getOriginal(), ['id' => $this->id])
: $this->getVersionableAttributes();
$version = new ModelVersion([
'versionable_type' => get_class($this),
'versionable_id' => $this->id,
'version_number' => $nextVersionNumber,
'data' => $dataToVersion,
'created_by' => auth()->id(),
]);
$version->save();
// Clean up old versions - keep only the last 50
$totalVersions = $this->versions()->count();
if ($totalVersions > 50) {
$this->versions()
->orderBy('version_number', 'asc')
->limit($totalVersions - 50)
->delete();
}
return $version;
} catch (\Throwable $e) {
// Log the error without breaking the main operation
\Log::error('Failed to create version', [
'model' => static::class,
'id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}Error Handling
The versioning system fails gracefully. If version creation fails, it logs the error but doesn't interrupt the main save operation. This prevents versioning bugs from breaking your application.
Helper Methods
Add convenience methods for working with versions:
/**
* Get the latest version.
*/
public function getLatestVersion(): ?ModelVersion
{
return $this->versions()->first();
}
/**
* Check if this model has any versions.
*/
public function hasVersions(): bool
{
return $this->versions()->exists();
}The ModelVersion Model
Create a simple model to store versions:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ModelVersion extends Model
{
protected $fillable = [
'versionable_type',
'versionable_id',
'version_number',
'data',
'created_by',
];
protected $casts = [
'data' => 'array',
];
public function versionable(): MorphTo
{
return $this->morphTo();
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}Usage Examples
Basic Usage
Simply add the trait to any model:
<?php
namespace App\Models;
use App\Traits\Versionable;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
use Versionable;
protected $fillable = ['title', 'content', 'status'];
}That's it! The model will now automatically track versions:
$article = Article::create([
'title' => 'Introduction to Laravel',
'content' => 'Laravel is a web application framework...',
'status' => 'draft',
]);
// Version 1 created automatically
$article->update(['status' => 'published']);
// Version 2 created automatically
// Access versions
$versions = $article->versions; // Collection of ModelVersion
$latest = $article->getLatestVersion();Customizing Versionable Fields
Override the trait methods to customize behavior:
class Article extends Model
{
use Versionable;
protected $fillable = ['title', 'content', 'status', 'views'];
/**
* Only version these specific fields.
*/
public function getVersionableFields(): array
{
return ['title', 'content', 'status'];
}
/**
* Ignore changes to 'updated_at' in version comparison.
*/
public function getIgnoredFields(): array
{
return ['updated_at'];
}
}Ignoring JSON Sub-fields
For models with JSON columns, you can ignore specific nested fields:
class UserSettings extends Model
{
use Versionable;
protected $casts = [
'preferences' => 'array',
'metadata' => 'array',
];
/**
* Ignore temporary or computed fields in JSON columns.
*/
public function getIgnoredFields(): array
{
return [
'preferences.last_viewed_at',
'metadata.session_data',
];
}
/**
* Ignore all fields starting with 'temp_'.
*/
public function getIgnoredPrefixes(): array
{
return ['temp_', 'cache_'];
}
}Viewing Version History
Display version history in your application:
public function versionHistory(Article $article)
{
$versions = $article->versions()
->with('creator')
->get()
->map(function ($version) {
return [
'version' => $version->version_number,
'changed_by' => $version->creator?->name ?? 'System',
'changed_at' => $version->created_at,
'data_snapshot' => $version->data,
];
});
return view('articles.history', compact('versions'));
}Restoring Previous Versions
Implement a restore feature:
public function restore(Article $article, int $versionNumber)
{
$version = $article->versions()
->where('version_number', $versionNumber)
->firstOrFail();
// Restore the data (this will create a new version)
$article->update($version->data);
return redirect()->back()->with('success', 'Article restored to version ' . $versionNumber);
}Advanced: Comparing Versions
Build a version comparison utility:
public function compareVersions(Article $article, int $version1, int $version2)
{
$v1 = $article->versions()->where('version_number', $version1)->first();
$v2 = $article->versions()->where('version_number', $version2)->first();
if (!$v1 || !$v2) {
abort(404);
}
$differences = [];
foreach ($v1->data as $key => $value) {
if (($v2->data[$key] ?? null) !== $value) {
$differences[$key] = [
'before' => $value,
'after' => $v2->data[$key] ?? null,
];
}
}
return view('articles.compare', compact('differences', 'v1', 'v2'));
}Performance Considerations
Automatic Cleanup
The trait automatically keeps only the last 50 versions per model. Adjust this in createVersion():
// Keep only the last 100 versions instead
if ($totalVersions > 100) {
$this->versions()
->orderBy('version_number', 'asc')
->limit($totalVersions - 100)
->delete();
}Querying Versions Efficiently
When displaying version history, eager load relationships:
$articles = Article::with(['versions' => function ($query) {
$query->latest()->limit(10);
}])->get();Indexing
The migration includes an index on (versionable_type, versionable_id, version_number) for efficient queries. For applications with heavy versioning, consider adding:
$table->index('created_at'); // For time-based queries
$table->index('created_by'); // For user-based queriesTesting
Write tests to ensure versioning works correctly:
use Tests\TestCase;
class VersionableTest extends TestCase
{
public function test_creates_initial_version_on_model_creation()
{
$article = Article::create([
'title' => 'Test Article',
'content' => 'Content',
]);
$this->assertTrue($article->hasVersions());
$this->assertEquals(1, $article->versions()->count());
$this->assertEquals(1, $article->getLatestVersion()->version_number);
}
public function test_creates_new_version_on_update()
{
$article = Article::create(['title' => 'Original']);
$article->update(['title' => 'Updated']);
$this->assertEquals(2, $article->versions()->count());
$this->assertEquals('Original', $article->versions()->skip(1)->first()->data['title']);
}
public function test_does_not_create_version_for_insignificant_changes()
{
$article = Article::create(['title' => 'Test']);
// Touch without actual changes
$article->touch();
// Should still only have 1 version (the initial one)
$this->assertEquals(1, $article->versions()->count());
}
}Troubleshooting
Versions Not Being Created
Common causes:
- The model doesn't have the
Versionabletrait - The
ModelVersionclass is not found or misconfigured - Database connection issues
- The
bootVersionable()method is being overridden
Check your logs for specific errors.
Too Many Versions
If you're creating versions too frequently:
- Review your
getIgnoredFields()implementation - Ensure
shouldCreateVersion()logic is working correctly - Consider reducing the retention limit from 50 to a smaller number
Performance Issues
For models with large JSON columns:
- Use
getIgnoredFields()to exclude large nested structures - Consider versioning only critical fields via
getVersionableFields() - Implement custom cleanup strategies for old versions
Conclusion
The Versionable trait provides a production-ready versioning system with minimal configuration. By leveraging Laravel's model events, polymorphic relationships, and intelligent change detection, you get automatic audit trails for any Eloquent model.
Key benefits:
- Zero Configuration: Add the trait and versioning works automatically
- Flexible: Customize which fields to track and how changes are detected
- Safe: Fails gracefully without breaking save operations
- Efficient: Automatic cleanup prevents version table bloat
- Production-Ready: Built for real-world Laravel applications
The trait pattern makes it easy to add versioning to existing models without refactoring, and the polymorphic design means you can version any number of different models with a single model_versions table.
For more advanced use cases, consider extending the trait with features like version diffs, scheduled cleanup jobs, or integration with external audit systems.

