When working on Filament admin panels, sometimes you need more than Git history, because git is storing only code changes. You may want to track every change to models/resources directly in your application, and even rollback changes to a previous state. For this reason, you should have a versioning system inside your Laravel Filament admin panel.
This guide shows you how to build a database-driven versioning system for Laravel Filament that:
- Records model/resource changes (create, update, delete)
- Stores version history in a dedicated table
- Lets you rollback individual changes from the Filament admin panel
Step 1: Create the versions
Table
Start by creating a migration for versions
table to store version details of all model histories as follows:
Schema::create('versions', function (Blueprint $table) {
$table->id();
$table->string('version');
$table->string('change_type');
$table->morphs('versionable');
$table->json('previous_values')->nullable();
$table->json('new_values')->nullable();
$table->json('changed_attributes')->nullable();
$table->foreignId('user_id')->nullable()->references('id')->on('users');
$table->timestamps();
});
Step 2: Define the Version
Model
Create a Version
model as follows:
class Version extends Model
{
protected $fillable = [
'version', 'change_type', 'versionable_type', 'versionable_id',
'previous_values', 'new_values', 'changed_attributes', 'user_id',
];
protected $casts = [
'previous_values' => 'array',
'new_values' => 'array',
'changed_attributes' => 'array',
];
public function versionable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Step 3: The Versioning Service
A service to manage version creation and rollbacks. It will perform the following operations:
- Mute mechanism: Prevent logging during rollback operations.
- Version bumping: Automatically increments patch version numbers.
- Recording methods:
recordCreated()
for creations,recordUpdated()
for updates,recordDeleted()
for deletions.
Rollback logic:
- Handles three types—
model_created
,model_updated
,model_deleted
. - Performs the inverse action (e.g. deletes a created record, restores previous values, or recreates a deleted record).
- Logs the rollback as a new entry with change_type
rollback
.
class VersioningService
{
/** @var int depth counter to silence logging while rolling back */
protected static int $muted = 0;
public static function muted(): bool
{
return self::$muted > 0;
}
public static function mute(callable $callback)
{
self::$muted++;
try {
return $callback();
} finally {
self::$muted--;
}
}
public function currentVersion(): string
{
return Version::query()->latest('id')->value('version') ?? '0.0.0';
}
protected function bumpPatch(string $changeType, Model $model, array $before, array $after, array $changed): Version
{
[$major, $minor, $patch] = array_map('intval', explode('.', $this->currentVersion()));
$patch++;
$new = implode('.', [$major, $minor, $patch]);
return Version::create([
'version' => $new,
'change_type' => $changeType,
'versionable_type' => $model::class,
'versionable_id' => $model->getKey(),
'previous_values' => $before,
'new_values' => $after,
'changed_attributes' => $changed,
'user_id' => optional(auth())->id(),
]);
}
public function recordCreated(Model $model, array $after): void
{
if (self::muted()) return;
$this->bumpPatch('model_created', $model, [], $after, array_keys($after));
}
public function recordUpdated(Model $model, array $before, array $after, array $changed): void
{
if (self::muted()) return;
$this->bumpPatch('model_updated', $model, $before, $after, $changed);
}
public function recordDeleted(Model $model, array $before): void
{
if (self::muted()) return;
$this->bumpPatch('model_deleted', $model, $before, [], array_keys($before));
}
/**
* Roll back one specific version row by ID.
* - model_created -> delete the created row
* - model_updated -> restore previous_values
* - model_deleted -> recreate from previous_values
*/
public function rollback(int $versionId): Version
{
$v = Version::query()->findOrFail($versionId);
return DB::transaction(function () use ($v) {
$modelClass = $v->versionable_type;
/** @var Model $proto */
$proto = new $modelClass;
$rolledBack = self::mute(function () use ($v, $modelClass, $proto) {
if ($v->change_type === 'model_created') {
$this->rollbackCreated($modelClass, $v);
} elseif ($v->change_type === 'model_updated') {
$this->rollbackUpdated($modelClass, $v);
} elseif ($v->change_type === 'model_deleted') {
$this->rollbackDeleted($modelClass, $v);
} else {
// no-op for "rollback" entries or unknown types
}
});
// Log a rollback entry (visible in history)
return Version::create([
'version' => $this->nextPatchAfter($v->version),
'change_type' => 'rollback',
'versionable_type' => $v->versionable_type,
'versionable_id' => $v->versionable_id,
'previous_values' => $v->new_values,
'new_values' => $v->previous_values,
'changed_attributes' => $v->changed_attributes,
'user_id' => optional(auth())->id(),
]);
});
}
protected function rollbackCreated(string $modelClass, Version $v): void
{
/** @var Model $modelClass */
$instance = $modelClass::query()->withoutGlobalScopes()
->find($v->versionable_id);
if ($instance) {
$instance->forceDelete(); // if soft deletes are used, you might prefer delete()
}
}
protected function rollbackUpdated(string $modelClass, Version $v): void
{
/** @var Model $instance */
$instance = $modelClass::query()->withoutGlobalScopes()
->findOrFail($v->versionable_id);
$prev = $v->previous_values ?? [];
// Only set attributes that actually exist/fillable
$fillable = $instance->getFillable();
$attrs = $fillable ? array_intersect_key($prev, array_flip($fillable)) : $prev;
// Quiet update to avoid firing observers
$instance->unguarded(function () use ($instance, $attrs) {
$instance->fill($attrs);
$instance->saveQuietly();
});
}
protected function rollbackDeleted(string $modelClass, Version $v): void
{
$prev = $v->previous_values ?? [];
if (empty($prev)) return;
/** @var Model $model */
$model = new $modelClass;
$model->unguarded(function () use ($model, $prev) {
// keep original primary key if present
$model->fill($prev);
// ensure key is set before save
if (array_key_exists($model->getKeyName(), $prev)) {
$model->setAttribute($model->getKeyName(), $prev[$model->getKeyName()]);
}
$model->saveQuietly();
});
}
protected function nextPatchAfter(string $base): string
{
[$M, $m, $p] = array_pad(array_map('intval', explode('.', $base)), 3, 0);
return implode('.', [$M, $m, $p + 1]);
}
}
Step 4: The Versionable Trait
Incorporate this trait to any model you want to track (e.g., Post
or Category
):
- On deleted: Record deletion with previous values.
- On updating: Cache “before” state of attributes.
- On updated: Compare before/after, then record updates.
- On created: Save the full initial state.
- On deleting: Cache attributes before deletion.
trait Versionable
{
// this is a PHP-only property, never touched by Eloquent
protected array $versioningCache = [];
public static function bootVersionable(): void
{
// Track "before" snapshot only of fields that are dirty
static::updating(function ($model) {
$dirty = $model->getDirty(); // fields being changed
$before = array_intersect_key($model->getOriginal(), $dirty);
$model->versioningCache['before'] = $before;
});
static::updated(function ($model) {
if (VersioningService::muted()) return;
$before = $model->versioningCache['before'] ?? [];
$after = array_intersect_key($model->getAttributes(), $before); // only changed
$changed = array_keys($before);
app(VersioningService::class)->recordUpdated($model, $before, $after, $changed);
unset($model->versioningCache['before']);
});
// For created: store only filled values
static::created(function ($model) {
if (VersioningService::muted()) return;
$after = $model->getAttributes();
app(VersioningService::class)->recordCreated($model, $after);
});
// For deleted: store full row (since everything is removed)
static::deleting(function ($model) {
$model->versioningCache['before'] = $model->getAttributes();
});
static::deleted(function ($model) {
if (VersioningService::muted()) return;
$before = $model->versioningCache['before'] ?? [];
app(VersioningService::class)->recordDeleted($model, $before);
unset($model->versioningCache['before']);
});
}
protected static function snapshot($model, bool $useOriginal = false): array
{
$arr = $useOriginal ? $model->getOriginal() : $model->getAttributes();
foreach ($model->getHidden() as $hidden) {
unset($arr[$hidden]);
}
return $arr;
}
}
Step 5: Create VersionResource
Create a VersionResource to list all versions stored in the database with rollback action as follows:
class VersionResource extends Resource
{
protected static ?string $model = Version::class;
protected static ?string $navigationIcon = 'heroicon-o-clock';
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make()
->schema([
TextEntry::make('versionable_type')
->label('Model'),
TextEntry::make('created_at')
->label('Date')
->dateTime(),
TextEntry::make('user.name')
->label('User'),
])
->columns(3),
Section::make()
->schema([
KeyValueEntry::make('previous_values')
->keyLabel('Field'),
]),
Section::make()
->schema([
KeyValueEntry::make('new_values')
->keyLabel('Field'),
])
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('version')
->searchable(),
Tables\Columns\TextColumn::make('change_type')
->colors([
'success' => 'model_created',
'warning' => 'model_updated',
'danger' => 'model_deleted',
'gray' => 'rollback',
])
->searchable(),
Tables\Columns\TextColumn::make('versionable_type')
->label('Model')
->wrap()
->searchable(),
Tables\Columns\TextColumn::make('versionable_id')
->label('ID'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\Action::make('rollback')
->label('Rollback')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading('Confirm Rollback')
->modalDescription('This will revert the model to its previous state for this version. No schema/code will be touched.')
->action(function (Version $record) {
app(\App\Services\VersioningService::class)->rollback($record->id);
\Filament\Notifications\Notification::make()
->title('Rollback completed')
->success()
->send();
})
->visible(
fn(Version $record) =>
in_array($record->change_type, ['model_created', 'model_updated', 'model_deleted'])
),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListVersions::route('/'),
];
}
}
On clicking on any version entry, the details will be opened in popup window.
All entries are also contains rollback action, when triggered, it reverts changes and shows a notification like Rollback completed.
Example Workflow
- Admin updates a model (e.g., a
Post
). - Version record is created capturing changes.
- Admin clicks “Rollback” in the panel.
- Model is reverted to its previous state, without altering schema or code.
This system stores only the changed fields, supports create/update/delete, and provides per-version rollback — ideal for auditing, debugging, or restoring data on the admin side.
Conclusion
You can enhance your Filament admin panel by introducing a system that captures and lets you rollback model changes—right within your application, beyond just Git history. You can also use this same system for auditing purpose as well, like when any changes was done and who has done those changes.
This is a very useful and lightweight database-driven versioning system, which can be used for different purpose.