Versionamento Automático de Modelos no Laravel: Construindo um Histórico de Auditoria com o Trait Versionable
Introdução
Manter um histórico completo de auditoria de mudanças em modelos é crucial para muitas aplicações. Seja para rastrear quem mudou o quê e quando, implementar funcionalidade de desfazer/refazer, ou cumprir com requisitos regulatórios, o versionamento automático fornece uma base robusta.
Este guia demonstra como construir um trait Versionable que captura automaticamente versões de modelos em cada operação de criação e atualização. O trait usa eventos de modelo do Laravel, relacionamentos polimórficos e detecção inteligente de mudanças para criar um sistema de versionamento de configuração zero.
Pré-requisitos
- Laravel 10.x ou superior (funciona com versões anteriores com ajustes menores)
- PHP 8.1 ou superior
- Conhecimento básico de modelos Eloquent e traits
- Familiaridade com eventos de modelo do Laravel
A Arquitetura
O sistema de versionamento consiste em três componentes principais:
- Trait Versionable - Se conecta automaticamente aos eventos de modelo
- Model ModelVersion - Armazena instantâneos de versões usando relacionamentos polimórficos
- TextUtil - Detecção inteligente de mudanças (manipula strings, arrays, JSON)
Esquema do Banco de Dados
Primeiro, crie uma migration para a tabela 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');
}
};Construindo o Trait Versionable
Método Boot e Hooks de Eventos
O trait usa a convenção boot{TraitName} do Laravel para registrar automaticamente listeners de eventos:
<?php
namespace App\Traits;
use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait Versionable
{
/**
* Anexa automaticamente o versionamento aos eventos do modelo.
*/
public static function bootVersionable(): void
{
static::updating(function ($model) {
if (method_exists($model, 'createVersion')) {
$model->createVersion();
}
});
static::created(function ($model) {
// Captura versão de linha de base inicial na criação
if (method_exists($model, 'createVersion')) {
$model->createVersion(true);
}
});
}
}Convenção Boot do Laravel
O Laravel automaticamente chama boot{TraitName}() quando um modelo usando o trait é inicializado. Isso fornece uma forma limpa de registrar listeners de eventos sem configuração explícita.
Relacionamento Polimórfico
Defina o relacionamento para registros de versão:
/**
* Obtém todas as versões para este modelo.
*/
public function versions(): MorphMany
{
return $this->morphMany(ModelVersion::class, 'versionable')
->orderBy('version_number', 'desc');
}Comportamento de Versionamento Personalizável
Forneça hooks para que os modelos personalizem quais atributos são versionados:
/**
* Obtém os atributos que devem ser versionados.
* Sobrescreva no seu modelo para personalizar o que é versionado.
*/
public function getVersionableAttributes(): array
{
return $this->attributesToArray();
}
/**
* Obtém os campos que devem disparar versionamento quando mudarem.
* Sobrescreva no seu modelo para especificar quais campos rastrear.
*/
public function getVersionableFields(): array
{
// Por padrão, rastreia todos os campos fillable
return $this->fillable;
}
/**
* Obtém os campos a ignorar ao verificar mudanças significativas.
* Sobrescreva no seu modelo para especificar campos ignorados.
*/
public function getIgnoredFields(): array
{
return [];
}
/**
* Obtém os prefixos de campos a ignorar ao verificar mudanças significativas.
* Sobrescreva no seu modelo para especificar prefixos ignorados.
*/
public function getIgnoredPrefixes(): array
{
return [];
}Detecção Inteligente de Mudanças
O trait usa um método utilitário para determinar se as mudanças são suficientemente significativas para justificar uma nova versão:
/**
* Verifica se uma versão deve ser criada baseando-se nas mudanças atuais.
*/
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 obter caminhos contextuais
$ignoredFields = $this->getIgnoredFields();
$contextualIgnoredFields = [];
foreach ($ignoredFields as $ignoredField) {
if (str_starts_with($ignoredField, "{$field}.")) {
// Remove o prefixo do campo para obter caminho relativo
$contextualIgnoredFields[] = substr($ignoredField, strlen($field) + 1);
} elseif (!str_contains($ignoredField, '.')) {
// Mantém campos sem pontos como estão
$contextualIgnoredFields[] = $ignoredField;
}
}
// Usa TextUtil para verificar mudanças significativas
if (TextUtil::hasSignificantChanges(
$original,
$current,
$contextualIgnoredFields,
$this->getIgnoredPrefixes()
)) {
return true;
}
}
}
return false;
}Detecção de Mudanças
O método TextUtil::hasSignificantChanges() manipula vários tipos de dados (strings, arrays, JSON) e pode ignorar campos específicos ou prefixos de campos. Isso previne criar versões para mudanças insignificantes como atualizações de timestamps ou campos computados.
Criando Versões
A lógica central de versionamento:
/**
* Cria uma nova versão deste modelo.
*/
public function createVersion(bool $force = false): ?ModelVersion
{
try {
// Só cria versão se houver mudanças significativas
if (!$force && !$this->shouldCreateVersion()) {
return null;
}
$nextVersionNumber = ($this->versions()->max('version_number') ?? 0) + 1;
// Ao chamar durante updating, captura o 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();
// Limpa versões antigas - mantém apenas as ú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 o erro sem quebrar a operação principal
\Log::error('Falha ao criar versão', [
'model' => static::class,
'id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}Tratamento de Erros
O sistema de versionamento falha de forma elegante. Se a criação de versão falhar, registra o erro mas não interrompe a operação principal de salvamento. Isso previne que bugs de versionamento quebrem sua aplicação.
Métodos Auxiliares
Adicione métodos de conveniência para trabalhar com versões:
/**
* Obtém a última versão.
*/
public function getLatestVersion(): ?ModelVersion
{
return $this->versions()->first();
}
/**
* Verifica se este modelo tem alguma versão.
*/
public function hasVersions(): bool
{
return $this->versions()->exists();
}O Model ModelVersion
Crie um model simples para armazenar versões:
<?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');
}
}Exemplos de Uso
Uso Básico
Simplesmente adicione o trait a qualquer 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'];
}É só isso! O modelo agora rastreará versões automaticamente:
$article = Article::create([
'title' => 'Introdução ao Laravel',
'content' => 'Laravel é um framework de aplicação web...',
'status' => 'draft',
]);
// Versão 1 criada automaticamente
$article->update(['status' => 'published']);
// Versão 2 criada automaticamente
// Acesse versões
$versions = $article->versions; // Coleção de ModelVersion
$latest = $article->getLatestVersion();Personalizando Campos Versionáveis
Sobrescreva os métodos do trait para personalizar comportamento:
class Article extends Model
{
use Versionable;
protected $fillable = ['title', 'content', 'status', 'views'];
/**
* Versiona apenas estes campos específicos.
*/
public function getVersionableFields(): array
{
return ['title', 'content', 'status'];
}
/**
* Ignora mudanças em 'updated_at' na comparação de versões.
*/
public function getIgnoredFields(): array
{
return ['updated_at'];
}
}Ignorando Sub-campos JSON
Para modelos com colunas JSON, você pode ignorar campos aninhados específicos:
class UserSettings extends Model
{
use Versionable;
protected $casts = [
'preferences' => 'array',
'metadata' => 'array',
];
/**
* Ignora campos temporários ou computados em colunas JSON.
*/
public function getIgnoredFields(): array
{
return [
'preferences.last_viewed_at',
'metadata.session_data',
];
}
/**
* Ignora todos os campos que começam com 'temp_'.
*/
public function getIgnoredPrefixes(): array
{
return ['temp_', 'cache_'];
}
}Visualizando Histórico de Versões
Mostre histórico de versões na sua aplicação:
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 Versões Anteriores
Implemente uma função de restauração:
public function restore(Article $article, int $versionNumber)
{
$version = $article->versions()
->where('version_number', $versionNumber)
->firstOrFail();
// Restaura os dados (isso criará uma nova versão)
$article->update($version->data);
return redirect()->back()->with('success', 'Artigo restaurado para versão ' . $versionNumber);
}Avançado: Comparando Versões
Construa uma utilidade de comparação de versões:
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'));
}Considerações de Performance
Limpeza Automática
O trait automaticamente mantém apenas as últimas 50 versões por modelo. Ajuste isso em createVersion():
// Mantém apenas as últimas 100 versões ao invés de 50
if ($totalVersions > 100) {
$this->versions()
->orderBy('version_number', 'asc')
->limit($totalVersions - 100)
->delete();
}Consultando Versões Eficientemente
Ao mostrar histórico de versões, carregue relacionamentos com eager loading:
$articles = Article::with(['versions' => function ($query) {
$query->latest()->limit(10);
}])->get();Indexação
A migration inclui um índice em (versionable_type, versionable_id, version_number) para consultas eficientes. Para aplicações com versionamento intensivo, considere adicionar:
$table->index('created_at'); // Para consultas baseadas em tempo
$table->index('created_by'); // Para consultas baseadas em usuárioTestes
Escreva testes para assegurar que o versionamento funciona corretamente:
use Tests\TestCase;
class VersionableTest extends TestCase
{
public function test_creates_initial_version_on_model_creation()
{
$article = Article::create([
'title' => 'Artigo de Teste',
'content' => 'Conteúdo',
]);
$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' => 'Atualizado']);
$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' => 'Teste']);
// Touch sem mudanças reais
$article->touch();
// Deve ainda ter apenas 1 versão (a inicial)
$this->assertEquals(1, $article->versions()->count());
}
}Solução de Problemas
Versões Não Estão Sendo Criadas
Causas comuns:
- O modelo não tem o trait
Versionable - A classe
ModelVersionnão é encontrada ou está mal configurada - Problemas de conexão com banco de dados
- O método
bootVersionable()está sendo sobrescrito
Verifique seus logs para erros específicos.
Muitas Versões
Se você está criando versões com muita frequência:
- Revise sua implementação de
getIgnoredFields() - Assegure que a lógica de
shouldCreateVersion()está funcionando corretamente - Considere reduzir o limite de retenção de 50 para um número menor
Problemas de Performance
Para modelos com colunas JSON grandes:
- Use
getIgnoredFields()para excluir estruturas aninhadas grandes - Considere versionar apenas campos críticos via
getVersionableFields() - Implemente estratégias de limpeza personalizadas para versões antigas
Conclusão
O trait Versionable fornece um sistema de versionamento pronto para produção com configuração mínima. Ao aproveitar os eventos de modelo do Laravel, relacionamentos polimórficos e detecção inteligente de mudanças, você obtém históricos de auditoria automáticos para qualquer modelo Eloquent.
Benefícios principais:
- Configuração Zero: Adicione o trait e o versionamento funciona automaticamente
- Flexível: Personalize quais campos rastrear e como mudanças são detectadas
- Seguro: Falha elegantemente sem quebrar operações de salvamento
- Eficiente: Limpeza automática previne inchaço da tabela de versões
- Pronto para Produção: Construído para aplicações Laravel do mundo real
O padrão de trait torna fácil adicionar versionamento a modelos existentes sem refatorar, e o design polimórfico significa que você pode versionar qualquer número de modelos diferentes com uma única tabela model_versions.
Para casos de uso mais avançados, considere estender o trait com recursos como diffs de versões, jobs de limpeza agendados ou integração com sistemas de auditoria externos.

