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:
- Trait Versionable - Se engancha automáticamente a eventos de modelo
- Modelo ModelVersion - Almacena instantáneas de versiones usando relaciones polimórficas
- 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:
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
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:
/**
* 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:
/**
* 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:
/**
* 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:
/**
* 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:
/**
* 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
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
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:
$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:
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:
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:
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:
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:
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():
// 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:
$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:
$table->index('created_at'); // Para consultas basadas en tiempo
$table->index('created_by'); // Para consultas basadas en usuarioPruebas
Escribe pruebas para asegurar que el versionado funciona correctamente:
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
ModelVersionno 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.
