Skip to content

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:

  1. Trait Versionable - Se conecta automaticamente aos eventos de modelo
  2. Model ModelVersion - Armazena instantâneos de versões usando relacionamentos polimórficos
  3. 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:

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

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

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

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

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

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

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

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

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

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

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 Versões Anteriores

Implemente uma função de restauração:

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

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

Considerações de Performance

Limpeza Automática

O trait automaticamente mantém apenas as últimas 50 versões por modelo. Ajuste isso em createVersion():

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

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

php
$table->index('created_at'); // Para consultas baseadas em tempo
$table->index('created_by'); // Para consultas baseadas em usuário

Testes

Escreva testes para assegurar que o versionamento funciona corretamente:

php
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 ModelVersion nã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.