En muchos proyectos es fundamental contar con un historial de cambios que nos permita saber quién modificó qué dato, en qué momento y cuál fue la diferencia antes y después. Este historial de cambios suele conocerse como un sistema de trazabilidad o revisiones (revision history). En este artículo aprenderás a desarrollar, desde cero y sin dependencias adicionales, un sistema que registre la creación, actualización y eliminación de registros en tu aplicación Laravel.
1. ¿En qué consiste nuestro sistema de revisiones?
La idea principal es tener un modelo Revision que guarde la información de:
El usuario que realizó el cambio (usamos un
morphs('owner'), para mayor flexibilidad).El modelo que fue modificado (usamos un
morphs('revisionable')).La columna modificada, junto con su valor anterior y su valor nuevo.
Un campo de comentarios para saber si fue creado, actualizado o eliminado.
Lo lograremos con:
Un modelo y migración
Revisionque almacenará los cambios.Un
traitRevisionableque se inyecta en cualquier modelo que deseemos auditar. Este trait intercepta los eventoscreated,updatedydeleted, y registra en la base de datos la información necesaria.Relaciones polimórficas para vincular el modelo
Revisioncon cualquier otro modelo (p. ej.,Ticket,Article, etc.).
2. Creando el modelo y migración Revision
Lo primero será generar el modelo Revision y su migración. Para ello, ejecuta:
sail artisan make:model Revision -mEsto creará el archivo de migración y el modelo. A continuación, ajusta el contenido de la migración (database/migrations/..._create_revisions_table.php) de la siguiente manera:
<?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('revisions', function (Blueprint $table) {
$table->id();
// Estas columnas nos permiten hacer relaciones polimórficas con "owner" y "revisionable"
$table->morphs('owner');
$table->morphs('revisionable');
$table->string('column_name')->nullable()->comment('The column name revised');
$table->text('old_value')->nullable()->comment('The old value of the column');
$table->text('new_value')->nullable()->comment('The new value of the column');
$table->text('comments')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('revisions');
}
};Ya con la migración lista, podemos definir el modelo Revision:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Revision extends Model
{
protected $fillable = [
'owner_id',
'owner_type',
'revisionable_id',
'revisionable_type',
'column_name',
'old_value',
'new_value',
'comments',
];
/**
* Relación polimórfica con el usuario o entidad que hizo el cambio.
*/
public function owner(): MorphTo
{
return $this->morphTo();
}
/**
* Relación polimórfica con el modelo que fue modificado.
*/
public function revisionable(): MorphTo
{
return $this->morphTo();
}
}Con esto, tenemos todo lo necesario para almacenar los cambios en la tabla revisions.
3. El trait Revisionable
Ahora crearemos un trait que se incluirá en cualquier modelo que queramos auditar. Este trait hará uso de los eventos de Eloquent (created, updated, deleted) para capturar los cambios y guardarlos en la tabla revisions.
Crea el archivo app/Traits/Revisionable.php con el siguiente contenido:
<?php
namespace App\Traits;
use App\Models\Revision;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait Revisionable
{
/**
* Definimos la relación polimórfica de un modelo "revisionable" con las "revisions".
*/
public function revisions(): MorphMany
{
return $this->morphMany(Revision::class, 'revisionable');
}
/**
* "Boot" del trait, donde enganchamos los eventos de Eloquent.
*/
public static function bootRevisionable(): void
{
static::created(function ($model)
{
// Recorremos las columnas que queremos auditar
foreach ($model->revisionableColumns as $column) {
$model->revisions()->create([
'owner_id' => auth()->id(),
'owner_type' => get_class(auth()->user()),
'revisionable_id' => $model->id,
'revisionable_type'=> get_class($model),
'column_name' => $column,
'old_value' => null,
'new_value' => $model->{$column},
'comments' => 'Created',
]);
}
});
static::updated(function ($model)
{
foreach ($model->revisionableColumns as $column) {
// "isDirty" nos indica si esa columna ha cambiado
if ($model->isDirty($column)) {
$model->revisions()->create([
'owner_id' => auth()->id(),
'owner_type' => get_class(auth()->user()),
'revisionable_id' => $model->id,
'revisionable_type'=> get_class($model),
'column_name' => $column,
'old_value' => $model->getOriginal($column),
'new_value' => $model->{$column},
'comments' => 'Updated',
]);
}
}
});
static::deleted(function ($model)
{
foreach ($model->revisionableColumns as $column) {
$model->revisions()->create([
'owner_id' => auth()->id(),
'owner_type' => get_class(auth()->user()),
'revisionable_id' => $model->id,
'revisionable_type'=> get_class($model),
'column_name' => $column,
'old_value' => $model->{$column},
'new_value' => null,
'comments' => 'Deleted',
]);
}
});
}
}¿Cómo funciona?
El trait define una relación polimórfica
revisions()con el modeloRevision.Dentro de
bootRevisionable(), enganchamos tres eventos importantes:created: Para cada columna auditable, se registra una fila que indica que el valor antiguo eranully el nuevo es el que se ha creado.updated: Solo registra una nueva fila cuando detecta que una columna marcada comorevisionableha cambiado.deleted: Marca el valor viejo como el que existía y el nuevo comonull, indicando que se ha eliminado.
Es fundamental que cada modelo defina una propiedad protected array $revisionableColumns donde listemos las columnas que queremos auditar.
4. Ejemplo de uso: Modelo Ticket
Imagina que tenemos un modelo Ticket con la siguiente migración:
sail artisan make:model Ticket -mEn la migración (resumen):
<?php
use App\Models\User;
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('tickets', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained();
$table->string('subject');
$table->unsignedTinyInteger('priority');
$table->text('message');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tickets');
}
};Y el modelo Ticket quedaría así:
<?php
namespace App\Models;
use App\Traits\Revisionable;
use Illuminate\Database\Eloquent\Model;
class Ticket extends Model
{
use Revisionable;
protected $fillable = ['user_id', 'subject', 'priority', 'message'];
// Sólo auditamos "subject" y "priority"
protected array $revisionableColumns = ['subject', 'priority'];
}Cuando se cree, actualice o elimine un ticket, se registrarán las revisiones correspondientes en la tabla revisions.
5. Otro ejemplo: Modelo Article
De manera análoga, podemos hacer lo mismo con un modelo Article:
sail artisan make:model Article -mMigración:
<?php use App\Models\User; 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('articles', function (Blueprint $table) { $table->id(); $table->foreignIdFor(User::class)->constrained(); $table->string('title'); $table->text('content'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('articles'); } };Modelo:
<?php namespace App\Models; use App\Traits\Revisionable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Article extends Model { use Revisionable; protected $fillable = ['user_id', 'title', 'content']; // Auditamos "title" y "content" protected array $revisionableColumns = ['title', 'content']; }
En este caso, se registrarán los cambios en el title y content cada vez que se modifique o elimine un artículo.
6. Cómo consultar las revisiones
Para acceder al historial de cambios de un modelo, basta con utilizar la relación revisions():
<?php
$ticket = Ticket::find(1);
$revisions = $ticket->revisions;
foreach ($revisions as $revision) {
echo "Columna: {$revision->column_name} ";
echo "Old: {$revision->old_value} -> New: {$revision->new_value} ";
echo "Realizado por: {$revision->owner_type} con ID {$revision->owner_id} ";
echo "Comentarios: {$revision->comments} ";
echo "Fecha: {$revision->created_at}";
}Lo mismo aplica para un Article u otro modelo que use el trait Revisionable.
7. Conclusiones y recomendaciones
Flexibilidad: El uso de relaciones polimórficas permite auditar múltiples modelos con una sola tabla de revisiones.
Escalabilidad: El trait está basado en eventos de Eloquent, por lo que no hay dependencias de paquetes externos.
Control de columnas: Al definir
$revisionableColumns, controlamos exactamente qué campos se registran en las revisiones, evitando sobrecargar la tabla con datos innecesarios.Consideraciones de rendimiento: Cada inserción o actualización genera nuevos registros en la tabla
revisions. Para aplicaciones con mucha actividad, es recomendable monitorizar el tamaño de esta tabla y definir estrategias de limpieza.Seguridad y permisos: El trait actual asume que hay un usuario autenticado con
auth()->user(). En entornos donde esta suposición no sea válida, conviene ajustar la lógica (por ejemplo, para manejar procesos automatizados).
Con esto, ya tienes un sistema de trazabilidad completo que te permitirá ver qué se modificó, quién lo modificó y cuáles eran los valores antes y después de cada cambio. ¡Espero que esta guía te ayude a mantener el control y la transparencia en tu aplicación Laravel!
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción