Laravel 中的自动模型版本控制:使用 Versionable Trait 构建审计跟踪
简介
维护模型变更的完整审计跟踪对许多应用程序至关重要。无论您需要跟踪谁在何时更改了什么,实现撤销/重做功能,还是遵守监管要求,自动版本控制都提供了强大的基础。
本指南演示如何构建一个 Versionable trait,在每次创建和更新操作时自动捕获模型版本。该 trait 使用 Laravel 的模型事件、多态关联和智能变更检测来创建零配置的版本控制系统。
前置条件
- Laravel 10.x 或更高版本(稍作调整即可适配早期版本)
- PHP 8.1 或更高版本
- 基本了解 Eloquent 模型和 traits
- 熟悉 Laravel 模型事件
架构设计
版本控制系统由三个主要组件组成:
- Versionable Trait - 自动挂钩到模型事件
- ModelVersion 模型 - 使用多态关联存储版本快照
- TextUtil - 智能变更检测(处理字符串、数组、JSON)
数据库模式
首先,为 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');
}
};构建 Versionable Trait
Boot 方法和事件钩子
trait 使用 Laravel 的 boot{TraitName} 约定自动注册事件监听器:
<?php
namespace App\Traits;
use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait Versionable
{
/**
* 自动将版本控制附加到模型事件。
*/
public static function bootVersionable(): void
{
static::updating(function ($model) {
if (method_exists($model, 'createVersion')) {
$model->createVersion();
}
});
static::created(function ($model) {
// 在创建时捕获初始基线版本
if (method_exists($model, 'createVersion')) {
$model->createVersion(true);
}
});
}
}Laravel Boot 约定
当使用 trait 的模型被启动时,Laravel 会自动调用 boot{TraitName}()。这提供了一种无需显式配置即可注册事件监听器的简洁方式。
多态关联
定义与版本记录的关联:
/**
* 获取此模型的所有版本。
*/
public function versions(): MorphMany
{
return $this->morphMany(ModelVersion::class, 'versionable')
->orderBy('version_number', 'desc');
}可自定义的版本控制行为
提供钩子让模型自定义哪些属性需要版本控制:
/**
* 获取应该进行版本控制的属性。
* 在模型中重写此方法以自定义版本控制的内容。
*/
public function getVersionableAttributes(): array
{
return $this->attributesToArray();
}
/**
* 获取更改时应触发版本控制的字段。
* 在模型中重写此方法以指定要跟踪的字段。
*/
public function getVersionableFields(): array
{
// 默认情况下,跟踪所有可填充字段
return $this->fillable;
}
/**
* 获取在检查重要变更时要忽略的字段。
* 在模型中重写此方法以指定忽略的字段。
*/
public function getIgnoredFields(): array
{
return [];
}
/**
* 获取在检查重要变更时要忽略的字段前缀。
* 在模型中重写此方法以指定忽略的前缀。
*/
public function getIgnoredPrefixes(): array
{
return [];
}智能变更检测
trait 使用工具方法来确定变更是否足够重要以创建新版本:
/**
* 根据当前变更检查是否应创建版本。
*/
public function shouldCreateVersion(): bool
{
$versionableFields = $this->getVersionableFields();
foreach ($versionableFields as $field) {
if ($this->isDirty($field)) {
$original = $this->getOriginal($field);
$current = $this->getAttribute($field);
// 过滤被忽略的字段以获取上下文感知路径
$ignoredFields = $this->getIgnoredFields();
$contextualIgnoredFields = [];
foreach ($ignoredFields as $ignoredField) {
if (str_starts_with($ignoredField, "{$field}.")) {
// 移除字段前缀以获取相对路径
$contextualIgnoredFields[] = substr($ignoredField, strlen($field) + 1);
} elseif (!str_contains($ignoredField, '.')) {
// 保持不带点的字段原样
$contextualIgnoredFields[] = $ignoredField;
}
}
// 使用 TextUtil 检查重要变更
if (TextUtil::hasSignificantChanges(
$original,
$current,
$contextualIgnoredFields,
$this->getIgnoredPrefixes()
)) {
return true;
}
}
}
return false;
}变更检测
TextUtil::hasSignificantChanges() 方法处理各种数据类型(字符串、数组、JSON),并可以忽略特定字段或字段前缀。这可以防止为不重要的变更(如时间戳更新或计算字段)创建版本。
创建版本
核心版本控制逻辑:
/**
* 为此模型创建新版本。
*/
public function createVersion(bool $force = false): ?ModelVersion
{
try {
// 仅在有重要变更时创建版本
if (!$force && !$this->shouldCreateVersion()) {
return null;
}
$nextVersionNumber = ($this->versions()->max('version_number') ?? 0) + 1;
// 在更新期间调用时,捕获原始状态
$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();
// 清理旧版本 - 仅保留最近 50 个
$totalVersions = $this->versions()->count();
if ($totalVersions > 50) {
$this->versions()
->orderBy('version_number', 'asc')
->limit($totalVersions - 50)
->delete();
}
return $version;
} catch (\Throwable $e) {
// 记录错误但不中断主要操作
\Log::error('版本创建失败', [
'model' => static::class,
'id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}错误处理
版本控制系统优雅地失败。如果版本创建失败,它会记录错误但不会中断主要保存操作。这可以防止版本控制错误破坏您的应用程序。
辅助方法
添加用于处理版本的便捷方法:
/**
* 获取最新版本。
*/
public function getLatestVersion(): ?ModelVersion
{
return $this->versions()->first();
}
/**
* 检查此模型是否有任何版本。
*/
public function hasVersions(): bool
{
return $this->versions()->exists();
}ModelVersion 模型
创建一个简单的模型来存储版本:
<?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');
}
}使用示例
基本使用
只需将 trait 添加到任何模型:
<?php
namespace App\Models;
use App\Traits\Versionable;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
use Versionable;
protected $fillable = ['title', 'content', 'status'];
}就是这样!模型现在会自动跟踪版本:
$article = Article::create([
'title' => 'Laravel 入门',
'content' => 'Laravel 是一个 Web 应用框架...',
'status' => 'draft',
]);
// 自动创建版本 1
$article->update(['status' => 'published']);
// 自动创建版本 2
// 访问版本
$versions = $article->versions; // ModelVersion 集合
$latest = $article->getLatestVersion();自定义版本控制字段
重写 trait 方法以自定义行为:
class Article extends Model
{
use Versionable;
protected $fillable = ['title', 'content', 'status', 'views'];
/**
* 仅对这些特定字段进行版本控制。
*/
public function getVersionableFields(): array
{
return ['title', 'content', 'status'];
}
/**
* 在版本比较中忽略 'updated_at' 的变更。
*/
public function getIgnoredFields(): array
{
return ['updated_at'];
}
}忽略 JSON 子字段
对于具有 JSON 列的模型,您可以忽略特定的嵌套字段:
class UserSettings extends Model
{
use Versionable;
protected $casts = [
'preferences' => 'array',
'metadata' => 'array',
];
/**
* 忽略 JSON 列中的临时或计算字段。
*/
public function getIgnoredFields(): array
{
return [
'preferences.last_viewed_at',
'metadata.session_data',
];
}
/**
* 忽略所有以 'temp_' 开头的字段。
*/
public function getIgnoredPrefixes(): array
{
return ['temp_', 'cache_'];
}
}查看版本历史
在应用程序中显示版本历史:
public function versionHistory(Article $article)
{
$versions = $article->versions()
->with('creator')
->get()
->map(function ($version) {
return [
'version' => $version->version_number,
'changed_by' => $version->creator?->name ?? '系统',
'changed_at' => $version->created_at,
'data_snapshot' => $version->data,
];
});
return view('articles.history', compact('versions'));
}恢复先前版本
实现恢复功能:
public function restore(Article $article, int $versionNumber)
{
$version = $article->versions()
->where('version_number', $versionNumber)
->firstOrFail();
// 恢复数据(这将创建新版本)
$article->update($version->data);
return redirect()->back()->with('success', '文章已恢复到版本 ' . $versionNumber);
}高级:版本比较
构建版本比较工具:
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'));
}性能考虑
自动清理
trait 自动为每个模型仅保留最近 50 个版本。在 createVersion() 中调整此设置:
// 保留最近 100 个版本而不是 50 个
if ($totalVersions > 100) {
$this->versions()
->orderBy('version_number', 'asc')
->limit($totalVersions - 100)
->delete();
}高效查询版本
显示版本历史时,使用预加载关联:
$articles = Article::with(['versions' => function ($query) {
$query->latest()->limit(10);
}])->get();索引
迁移包含在 (versionable_type, versionable_id, version_number) 上的索引以实现高效查询。对于大量版本控制的应用程序,考虑添加:
$table->index('created_at'); // 用于基于时间的查询
$table->index('created_by'); // 用于基于用户的查询测试
编写测试以确保版本控制正确工作:
use Tests\TestCase;
class VersionableTest extends TestCase
{
public function test_creates_initial_version_on_model_creation()
{
$article = Article::create([
'title' => '测试文章',
'content' => '内容',
]);
$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' => '原始']);
$article->update(['title' => '已更新']);
$this->assertEquals(2, $article->versions()->count());
$this->assertEquals('原始', $article->versions()->skip(1)->first()->data['title']);
}
public function test_does_not_create_version_for_insignificant_changes()
{
$article = Article::create(['title' => '测试']);
// 无实际变更的触碰
$article->touch();
// 应该仍然只有 1 个版本(初始版本)
$this->assertEquals(1, $article->versions()->count());
}
}故障排除
版本未被创建
常见原因:
- 模型没有
Versionabletrait ModelVersion类未找到或配置错误- 数据库连接问题
bootVersionable()方法被重写
检查日志以获取具体错误。
版本过多
如果您创建版本过于频繁:
- 检查您的
getIgnoredFields()实现 - 确保
shouldCreateVersion()逻辑正常工作 - 考虑将保留限制从 50 减少到更小的数字
性能问题
对于具有大型 JSON 列的模型:
- 使用
getIgnoredFields()排除大型嵌套结构 - 考虑仅通过
getVersionableFields()对关键字段进行版本控制 - 为旧版本实现自定义清理策略
结论
Versionable trait 提供了一个最小配置的生产就绪版本控制系统。通过利用 Laravel 的模型事件、多态关联和智能变更检测,您可以为任何 Eloquent 模型获得自动审计跟踪。
主要优势:
- 零配置:添加 trait 即可自动工作
- 灵活:自定义要跟踪的字段和变更检测方式
- 安全:优雅失败而不会破坏保存操作
- 高效:自动清理防止版本表膨胀
- 生产就绪:为真实的 Laravel 应用程序构建
trait 模式使得在不重构的情况下向现有模型添加版本控制变得容易,多态设计意味着您可以使用单个 model_versions 表对任意数量的不同模型进行版本控制。
对于更高级的用例,考虑扩展 trait 以包含版本差异、计划清理作业或与外部审计系统集成等功能。

