Skip to content

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:

  1. Versionable Trait - Automatically hooks into model events
  2. ModelVersion Model - Stores version snapshots using polymorphic relationships
  3. TextUtil - Intelligent change detection (handles strings, arrays, JSON)

Database Schema

First, create a migration for the model_versions table:

php
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
<?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:

php
/**
 * 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:

php
/**
 * 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:

php
/**
 * 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:

php
/**
 * 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:

php
/**
 * 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
<?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
<?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:

php
$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:

php
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:

php
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:

php
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:

php
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:

php
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():

php
// 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:

php
$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:

php
$table->index('created_at'); // For time-based queries
$table->index('created_by'); // For user-based queries

Testing

Write tests to ensure versioning works correctly:

php
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 Versionable trait
  • The ModelVersion class 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.