Mastering Version Control in Laravel Filament: Build Your Own Rollback System

Learn how to implement a powerful versioning and rollback system in Laravel using Filament. Track model changes, record history, and rollback updates, deletions, or creations with ease.

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.

Laravel Filament MD5 Password Authentication Guide

Learn how to enable MD5 password authentication in Laravel Filament by creating a custom hasher and updating the AuthServiceProvider for legacy systems.

⚠️ Security Warning:

MD5 is not secure for password hashing and should only be used for compatibility with legacy systems. Consider migrating to bcrypt or argon2 for secure password storage.

If you are working on a Laravel application using Filament Admin and you already have users table with passwords are stored as MD5 hashes, Laravel makes it possible to register a custom hash driver so authentication still works.

In this article, we will learn how to develop custom hash driver for legacy systems, which can use Filament Admin.

Step 1 – Create the MD5 Hasher Class

Laravel’s hash drivers only need three methods: make, check, and needsRehash. We’ll create a simple MD5-based hasher class as follows:

namespace App\Hashing;

class Md5Hasher
{
    public function make($value, array $options = [])
    {
        return md5($value);
    }

    public function check($value, $hashedValue, array $options = [])
    {
        return md5($value) === $hashedValue;
    }

    public function needsRehash($hashedValue, array $options = [])
    {
        return false;
    }
}

In this hasher class, we used md5 function to encrypt the data.

Step 2 – Register the MD5 Driver in AuthServiceProvider

We need to register this hash driver class to the app/Providers/AuthServiceProvider file as follows,

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Hash;
use App\Hashing\Md5Hasher;

class AuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->make('hash')->extend('md5', function() {
            return new Md5Hasher;
        });
    }
}

Step 3 – Set Laravel to Use MD5 by Default

After register to the service provider, we need to change the authentication driver for laravel. You can do it from .env file or config/hashing.php file.

In .env:

HASH_DRIVER=md5

Or in config/hashing.php:

'default' => env('HASH_DRIVER', 'md5'),

Step 4 – Ensure Passwords Are Stored as MD5

When creating or updating users, Laravel will now use MD5 automatically:

use Illuminate\Support\Facades\Hash;
use App\Models\User;

$user = new User();
$user->name = 'Admin';
$user->email = 'admin@example.com';
$user->password = Hash::make('secret'); // stored as MD5
$user->save();

How It Works in Filament

Filament uses Laravel’s built-in authentication (Auth::attempt()), which in turn uses Hash::check(). Because we overrode the hash driver, Filament logins will automatically work with MD5 passwords.

Bonus: Supporting Both MD5 and bcrypt

If you’re migrating from MD5 to bcrypt, you can check both formats:

public function check($value, $hashedValue, array $options = [])
{
    if (md5($value) === $hashedValue) {
        return true; // MD5 match
    }

    return password_verify($value, $hashedValue); // bcrypt/argon
}

This way, old MD5 passwords still work, but you can rehash them to bcrypt on the next login.

Final Thoughts

  • Use MD5 only for compatibility with old systems.
  • If possible, rehash MD5 passwords to bcrypt or argon2 after the first successful login.
  • Filament will automatically use your custom MD5 logic since it relies on Laravel’s authentication system.

How to Insert Repeater Field Entries as Rows to Table in Laravel Filament

Learn how to convert repeater field JSON data into individual table rows in Laravel Filament using a custom button action. A practical guide for syncing structured form data.

When building admin panels using Laravel Filament, the Repeater field is a powerful way to collect dynamic sets of data — such as specifications, tags, or features. Often, these repeater entries are stored as a JSON array in the database. But what if you want to convert those JSON entries into individual rows in another table — for analytics, reporting, or normalization?

In this article, we’ll walk through how to:

  • Collect data using a Repeater field (stored as JSON)
  • Add a button to your Filament admin to insert each entry as a row in another table
  • Do this on-demand, without preloading data from the related table

The Use Case

Let’s assume you’re managing products with technical specifications.

  • You store specifications in a specifications JSON column of the products table using a Filament Repeater.
  • When a button is clicked (e.g., “Insert Repeater Entries”), each specification should be copied into a product_specifications table, with one row per entry.

Step-by-Step Guide

Step 1: Set Up the Repeater Field

Add a specifictions repeater field with multiple fields in product form as follows:

Repeater::make('specifications')
    ->schema([
        TextInput::make('key'),
        TextInput::make('value'),
        TextInput::make('unit'),
        TextInput::make('description'),
        TextInput::make('notes'),
    ])

This will create specifications repeater field in which you can add multiple rows of specifications for any product. This data is stored in the product_pecifications column of your products table as a JSON array.

Step 2: Create the Target Table

Create a migration for new table to hold individual specification entries using this command:

php artisan make:model ProductSpecification -m

It will create 2 files as follows:

  • Model File: app/Models/ProductSpecification.php
  • Migration File: database/migrations/xxxx_xx_xx_xxxxxx_create_product_specifications_table.php

Step 3: Run the Migration File

Update the migration file as per your repeater field entry as follows:

Schema::create('product_specifications', function (Blueprint $table) {
    $table->id();
    $table->foreignId('product_id')->constrained()->onDelete('cascade');
    $table->string('key')->nullable();
    $table->string('value')->nullable();
    $table->string('unit')->nullable();
    $table->text('description')->nullable();
    $table->text('notes')->nullable();
    $table->timestamps();
});

You can now run the migration using migrate command.

php artisan migrate

It will create a product_specifictions table in the database.

Step 4: Add a Button to Trigger the Insert

In your ProductResource\Pages\ViewProduct or EditProduct, add a custom action to generate specifications entries from the repeater field in product form.

use Filament\Actions\Action;

public function getHeaderActions(): array
{
    return [
        Action::make('Insert Repeater Entries')
            ->requiresConfirmation()
            ->action(function () {
                $specs = $this->record->specifications ?? [];

                if (!is_array($specs)) {
                    $specs = json_decode($specs, true) ?? [];
                }

                foreach ($specs as $spec) {
                    \App\Models\ProductSpecification::create([
                        'product_id'  => $this->record->id,
                        'key'         => $spec['key'] ?? null,
                        'value'       => $spec['value'] ?? null,
                        'unit'        => $spec['unit'] ?? null,
                        'description' => $spec['description'] ?? null,
                        'notes'       => $spec['notes'] ?? null,
                    ]);
                }

                $this->notify('success', 'Specifications inserted successfully.');
            }),
    ];
}

You can name the button anything, such as “Sync Specifications” or “Publish to Table”. It will manually extract the JSON data and insert it as rows in the product_specifications table.

Benefits of This Approach

  • Keeps your form simple and user friendly by storing repeater data in JSON.
  • Normalizes data later when needed — perfect for one-time inserts or batch operations.
  • Doesn’t require eager loading or nested relationship editing.

Avoid Duplicate Inserts

You can prevent duplicate imports by checking if rows already exists by adding the following check before adding the specifications in above code:

if ($this->record->productSpecifications()->exists()) {
    $this->notify('warning', 'Specifications already exist.');
    return;
}

Conclusion

Using repeater field gives you flexibility in how you manage structured, dynamic data in Laravel Filament. You can let users manage repeater fields easily, while keeping your database clean and relational by syncing data to separate tables on demand.

Whether for analytics, reporting, or integration, separating repeater entries into rows gives you the best of both worlds: JSON-based forms with relational data power.