Skip to content

Versionado Automático de Modelos en Laravel: Construyendo un Historial de Auditoría con el Trait Versionable

Introducción

Mantener un historial completo de auditoría de cambios en modelos es crucial para muchas aplicaciones. Ya sea que necesites rastrear quién cambió qué y cuándo, implementar funcionalidad de deshacer/rehacer, o cumplir con requisitos regulatorios, el versionado automático proporciona una base robusta.

Esta guía demuestra cómo construir un trait Versionable que captura automáticamente versiones de modelos en cada operación de creación y actualización. El trait usa eventos de modelo de Laravel, relaciones polimórficas y detección inteligente de cambios para crear un sistema de versionado de configuración cero.

Prerrequisitos

  • Laravel 10.x o superior (funciona con versiones anteriores con ajustes menores)
  • PHP 8.1 o superior
  • Conocimiento básico de modelos Eloquent y traits
  • Familiaridad con eventos de modelo de Laravel

La Arquitectura

El sistema de versionado consiste en tres componentes principales:

  1. Trait Versionable - Se engancha automáticamente a eventos de modelo
  2. Modelo ModelVersion - Almacena instantáneas de versiones usando relaciones polimórficas
  3. TextUtil - Detección inteligente de cambios (maneja strings, arrays, JSON)

Esquema de Base de Datos

Primero, crea una migración para la tabla model_versions:

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');
    }
};

Construyendo el Trait Versionable

Método Boot y Ganchos de Eventos

El trait usa la convención boot{TraitName} de Laravel para registrar automáticamente listeners de eventos:

php
<?php

namespace App\Traits;

use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;

trait Versionable
{
    /**
     * Adjunta automáticamente el versionado a los eventos del modelo.
     */
    public static function bootVersionable(): void
    {
        static::updating(function ($model) {
            if (method_exists($model, 'createVersion')) {
                $model->createVersion();
            }
        });

        static::created(function ($model) {
            // Captura versión de línea base inicial en creación
            if (method_exists($model, 'createVersion')) {
                $model->createVersion(true);
            }
        });
    }
}

Convención Boot de Laravel

Laravel automáticamente llama boot{TraitName}() cuando un modelo usando el trait es iniciado. Esto proporciona una forma limpia de registrar listeners de eventos sin configuración explícita.

Relación Polimórfica

Define la relación a registros de versión:

php
/**
 * Obtiene todas las versiones para este modelo.
 */
public function versions(): MorphMany
{
    return $this->morphMany(ModelVersion::class, 'versionable')
        ->orderBy('version_number', 'desc');
}

Comportamiento de Versionado Personalizable

Proporciona ganchos para que los modelos personalicen qué atributos se versionan:

php
/**
 * Obtiene los atributos que deben ser versionados.
 * Sobrescribe en tu modelo para personalizar qué se versiona.
 */
public function getVersionableAttributes(): array
{
    return $this->attributesToArray();
}

/**
 * Obtiene los campos que deben disparar versionado cuando cambian.
 * Sobrescribe en tu modelo para especificar qué campos rastrear.
 */
public function getVersionableFields(): array
{
    // Por defecto, rastrea todos los campos fillable
    return $this->fillable;
}

/**
 * Obtiene los campos a ignorar al verificar cambios significativos.
 * Sobrescribe en tu modelo para especificar campos ignorados.
 */
public function getIgnoredFields(): array
{
    return [];
}

/**
 * Obtiene los prefijos de campos a ignorar al verificar cambios significativos.
 * Sobrescribe en tu modelo para especificar prefijos ignorados.
 */
public function getIgnoredPrefixes(): array
{
    return [];
}

Detección Inteligente de Cambios

El trait usa un método utilitario para determinar si los cambios son lo suficientemente significativos para justificar una nueva versión:

php
/**
 * Verifica si se debe crear una versión basándose en los cambios actuales.
 */
public function shouldCreateVersion(): bool
{
    $versionableFields = $this->getVersionableFields();

    foreach ($versionableFields as $field) {
        if ($this->isDirty($field)) {
            $original = $this->getOriginal($field);
            $current = $this->getAttribute($field);

            // Filtra campos ignorados para obtener rutas contextuales
            $ignoredFields = $this->getIgnoredFields();
            $contextualIgnoredFields = [];

            foreach ($ignoredFields as $ignoredField) {
                if (str_starts_with($ignoredField, "{$field}.")) {
                    // Remueve el prefijo del campo para obtener ruta relativa
                    $contextualIgnoredFields[] = substr($ignoredField, strlen($field) + 1);
                } elseif (!str_contains($ignoredField, '.')) {
                    // Mantiene campos sin puntos tal cual
                    $contextualIgnoredFields[] = $ignoredField;
                }
            }

            // Usa TextUtil para verificar cambios significativos
            if (TextUtil::hasSignificantChanges(
                $original,
                $current,
                $contextualIgnoredFields,
                $this->getIgnoredPrefixes()
            )) {
                return true;
            }
        }
    }

    return false;
}

Detección de Cambios

El método TextUtil::hasSignificantChanges() maneja varios tipos de datos (strings, arrays, JSON) y puede ignorar campos específicos o prefijos de campos. Esto previene crear versiones para cambios insignificantes como actualizaciones de timestamps o campos computados.

Creando Versiones

La lógica central de versionado:

php
/**
 * Crea una nueva versión de este modelo.
 */
public function createVersion(bool $force = false): ?ModelVersion
{
    try {
        // Solo crea versión si hay cambios significativos
        if (!$force && !$this->shouldCreateVersion()) {
            return null;
        }

        $nextVersionNumber = ($this->versions()->max('version_number') ?? 0) + 1;

        // Al llamarse durante updating, captura el estado original
        $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();

        // Limpia versiones antiguas - mantiene solo las últimas 50
        $totalVersions = $this->versions()->count();
        if ($totalVersions > 50) {
            $this->versions()
                ->orderBy('version_number', 'asc')
                ->limit($totalVersions - 50)
                ->delete();
        }

        return $version;
    } catch (\Throwable $e) {
        // Registra el error sin romper la operación principal
        \Log::error('Falló la creación de versión', [
            'model' => static::class,
            'id' => $this->id,
            'error' => $e->getMessage(),
        ]);

        return null;
    }
}

Manejo de Errores

El sistema de versionado falla de forma elegante. Si la creación de versión falla, registra el error pero no interrumpe la operación principal de guardado. Esto previene que bugs de versionado rompan tu aplicación.

Métodos Auxiliares

Agrega métodos de conveniencia para trabajar con versiones:

php
/**
 * Obtiene la última versión.
 */
public function getLatestVersion(): ?ModelVersion
{
    return $this->versions()->first();
}

/**
 * Verifica si este modelo tiene alguna versión.
 */
public function hasVersions(): bool
{
    return $this->versions()->exists();
}

El Modelo ModelVersion

Crea un modelo simple para almacenar versiones:

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');
    }
}

Ejemplos de Uso

Uso Básico

Simplemente agrega el trait a cualquier modelo:

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'];
}

¡Eso es todo! El modelo ahora rastreará versiones automáticamente:

php
$article = Article::create([
    'title' => 'Introducción a Laravel',
    'content' => 'Laravel es un framework de aplicaciones web...',
    'status' => 'draft',
]);
// Versión 1 creada automáticamente

$article->update(['status' => 'published']);
// Versión 2 creada automáticamente

// Accede a versiones
$versions = $article->versions; // Colección de ModelVersion
$latest = $article->getLatestVersion();

Personalizando Campos Versionables

Sobrescribe los métodos del trait para personalizar comportamiento:

php
class Article extends Model
{
    use Versionable;

    protected $fillable = ['title', 'content', 'status', 'views'];

    /**
     * Solo versiona estos campos específicos.
     */
    public function getVersionableFields(): array
    {
        return ['title', 'content', 'status'];
    }

    /**
     * Ignora cambios en 'updated_at' en la comparación de versiones.
     */
    public function getIgnoredFields(): array
    {
        return ['updated_at'];
    }
}

Ignorando Sub-campos JSON

Para modelos con columnas JSON, puedes ignorar campos anidados específicos:

php
class UserSettings extends Model
{
    use Versionable;

    protected $casts = [
        'preferences' => 'array',
        'metadata' => 'array',
    ];

    /**
     * Ignora campos temporales o computados en columnas JSON.
     */
    public function getIgnoredFields(): array
    {
        return [
            'preferences.last_viewed_at',
            'metadata.session_data',
        ];
    }

    /**
     * Ignora todos los campos que empiezan con 'temp_'.
     */
    public function getIgnoredPrefixes(): array
    {
        return ['temp_', 'cache_'];
    }
}

Viendo Historial de Versiones

Muestra historial de versiones en tu aplicación:

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 ?? 'Sistema',
                'changed_at' => $version->created_at,
                'data_snapshot' => $version->data,
            ];
        });

    return view('articles.history', compact('versions'));
}

Restaurando Versiones Previas

Implementa una función de restauración:

php
public function restore(Article $article, int $versionNumber)
{
    $version = $article->versions()
        ->where('version_number', $versionNumber)
        ->firstOrFail();

    // Restaura los datos (esto creará una nueva versión)
    $article->update($version->data);

    return redirect()->back()->with('success', 'Artículo restaurado a versión ' . $versionNumber);
}

Avanzado: Comparando Versiones

Construye una utilidad de comparación de versiones:

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'));
}

Consideraciones de Rendimiento

Limpieza Automática

El trait automáticamente mantiene solo las últimas 50 versiones por modelo. Ajusta esto en createVersion():

php
// Mantiene solo las últimas 100 versiones en lugar de 50
if ($totalVersions > 100) {
    $this->versions()
        ->orderBy('version_number', 'asc')
        ->limit($totalVersions - 100)
        ->delete();
}

Consultando Versiones Eficientemente

Al mostrar historial de versiones, carga relaciones con eager loading:

php
$articles = Article::with(['versions' => function ($query) {
    $query->latest()->limit(10);
}])->get();

Indexación

La migración incluye un índice en (versionable_type, versionable_id, version_number) para consultas eficientes. Para aplicaciones con versionado intensivo, considera agregar:

php
$table->index('created_at'); // Para consultas basadas en tiempo
$table->index('created_by'); // Para consultas basadas en usuario

Pruebas

Escribe pruebas para asegurar que el versionado funciona correctamente:

php
use Tests\TestCase;

class VersionableTest extends TestCase
{
    public function test_creates_initial_version_on_model_creation()
    {
        $article = Article::create([
            'title' => 'Artículo de Prueba',
            'content' => 'Contenido',
        ]);

        $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' => 'Actualizado']);

        $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' => 'Prueba']);

        // Touch sin cambios reales
        $article->touch();

        // Debería seguir teniendo solo 1 versión (la inicial)
        $this->assertEquals(1, $article->versions()->count());
    }
}

Solución de Problemas

Versiones No Se Están Creando

Causas comunes:

  • El modelo no tiene el trait Versionable
  • La clase ModelVersion no se encuentra o está mal configurada
  • Problemas de conexión a base de datos
  • El método bootVersionable() está siendo sobrescrito

Revisa tus logs para errores específicos.

Demasiadas Versiones

Si estás creando versiones con demasiada frecuencia:

  • Revisa tu implementación de getIgnoredFields()
  • Asegura que la lógica de shouldCreateVersion() está funcionando correctamente
  • Considera reducir el límite de retención de 50 a un número menor

Problemas de Rendimiento

Para modelos con columnas JSON grandes:

  • Usa getIgnoredFields() para excluir estructuras anidadas grandes
  • Considera versionar solo campos críticos vía getVersionableFields()
  • Implementa estrategias de limpieza personalizadas para versiones antiguas

Conclusión

El trait Versionable proporciona un sistema de versionado listo para producción con configuración mínima. Al aprovechar los eventos de modelo de Laravel, relaciones polimórficas y detección inteligente de cambios, obtienes historiales de auditoría automáticos para cualquier modelo Eloquent.

Beneficios clave:

  • Configuración Cero: Agrega el trait y el versionado funciona automáticamente
  • Flexible: Personaliza qué campos rastrear y cómo se detectan cambios
  • Seguro: Falla elegantemente sin romper operaciones de guardado
  • Eficiente: Limpieza automática previene inflación de tabla de versiones
  • Listo para Producción: Construido para aplicaciones Laravel del mundo real

El patrón de trait hace fácil agregar versionado a modelos existentes sin refactorizar, y el diseño polimórfico significa que puedes versionar cualquier número de modelos diferentes con una sola tabla model_versions.

Para casos de uso más avanzados, considera extender el trait con características como diffs de versiones, trabajos de limpieza programados o integración con sistemas de auditoría externos.