Record Freezer plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

Record Freezer

Freeze individual Eloquent records against modification — finalised contracts, audited financial periods, legal holds

Tags: Panels Developer Tool Action
Supported versions:
5.x 4.x
Mustafa Khaled avatar Author: Mustafa Khaled

Documentation

Freeze individual Eloquent records against modification — finalised contracts, audited financial periods, legal holds — for Filament.

When a record is frozen:

  • Any attempt to update(), save() (with dirty attributes) or delete() it throws a RecordFrozenException — from the UI, a job, a policy, or tinker.
  • Filament resources using the HandlesFrozenRecords trait have their canEdit() / canDelete() disabled automatically, and row-level edit/delete actions are hidden via FreezableActionGroup.
  • A polymorphic audit trail records who froze it, when, and why. Unfreezing preserves full history (freeze → unfreeze → re-freeze creates a new row each time) with who / when / reason for both sides.

#Requirements

  • PHP 8.2+
  • Laravel 11+ / Laravel 13
  • Filament v4+

#Installation

The package is path-linked inside this monorepo. From the project root:

composer require wezlo/filament-record-freezer
php artisan migrate

Register the plugin on the panel(s) where you want the admin resource:

use Wezlo\FilamentRecordFreezer\FilamentRecordFreezerPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugins([
            FilamentRecordFreezerPlugin::make(),
        ]);
}

#Make a model freezable

Add the HasFreezes trait to any Eloquent model:

use Wezlo\FilamentRecordFreezer\Concerns\HasFreezes;

class Contract extends Model
{
    use HasFreezes;
}

You can now:

$contract->freeze('Legal hold — case #4412');   // throws if already frozen
$contract->isFrozen();                           // true
$contract->activeFreeze;                         // current Freeze row (or null)
$contract->freezes;                              // full history, newest first

$contract->unfreeze('Case closed — release');    // sets unfrozen_at, keeps history
$contract->freeze('Re-opened for audit');        // new Freeze row, history preserved

$contract->update(['amount' => 5000]);           // → RecordFrozenException
$contract->delete();                             // → RecordFrozenException

Observer coverage: updating, deleting, and (for models using SoftDeletes) restoring.

#Filament integration — FreezableActionGroup

The row-level UX is driven by FreezableActionGroup, a dropdown action group that bundles Freeze + Unfreeze alongside your own row actions. The host's actions (edit, delete, custom workflows) are passed through unfrozenActions() and are automatically hidden when the record is frozen — so there's no path to modifying a frozen record from the table.

use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Resource;
use Wezlo\FilamentRecordFreezer\Actions\FreezableActionGroup;
use Wezlo\FilamentRecordFreezer\Concerns\HandlesFrozenRecords;

class ContractResource extends Resource
{
    use HandlesFrozenRecords;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('title'),
                static::freezableColumn(),          // lock icon + tooltip
            ])
            ->recordActions([
                ViewAction::make(),                 // always available
                FreezableActionGroup::make()
                    ->canFreeze(fn ($record) => auth()->user()->is_admin)
                    ->canUnfreeze(fn ($record) => auth()->user()->hasRole('compliance'))
                    ->unfrozenActions([
                        EditAction::make(),         // auto-hidden when frozen
                        DeleteAction::make(),       // auto-hidden when frozen
                    ]),
            ]);
    }
}

canFreeze() and canUnfreeze() each accept a bool or a closure fn (Model $record, ?Authenticatable $user) => bool. Return false to hide that action for the current user / row. Default: allow.

HandlesFrozenRecords is still recommended as an authorization backstop — it overrides the resource's canEdit() and canDelete() so frozen records can't be modified even if a user hits the edit/delete URL directly or via a bulk action.

#Audit trail — who froze / unfroze

Every freeze and unfreeze is recorded on the freezes table as an immutable row. Unfreezing does not delete the row — it sets unfrozen_at, unfrozen_by, and unfreeze_reason on the active row, preserving the full history.

$contract->freezes;                                // full history, newest first
$contract->freezes->first()->frozen_by;            // user id who froze
$contract->freezes->first()->unfrozenBy?->name;    // who released it (if released)
$contract->freezes->first()->unfreeze_reason;      // why it was released
$contract->activeFreeze;                           // current Freeze row (null if not frozen)

Table schema:

Column Purpose
freezable_type, freezable_id Polymorphic target
frozen_by, frozen_at, reason Who froze, when, why
unfrozen_by, unfrozen_at, unfreeze_reason Who released, when, why (null while active)

#Central admin resource

The plugin ships FreezeResource — a central list of every freeze, active or released, across every polymorphic model. Filters by status (active / released) and model type. Columns for both freeze and unfreeze metadata (reason, user, timestamps) are visible by default and toggleable. UnfreezeAction is available inline and only on active rows — released rows never show it, even when the underlying record has a newer active freeze.

Disable it from a specific panel:

FilamentRecordFreezerPlugin::make()->registerResource(false)

Or change its navigation:

FilamentRecordFreezerPlugin::make()
    ->navigationGroup('Audit & Compliance')
    ->navigationIcon('heroicon-o-shield-check')

#Configuration reference

Publish with php artisan vendor:publish --tag="filament-record-freezer-config".

Key Default Description
user_model App\Models\User Model used for frozen_by / unfrozen_by relationships.
table_name freezes Name of the polymorphic table.
ignored_columns [] Columns whose dirty writes are allowed even while frozen (e.g. updated_at).
require_reason true Enforce a non-empty reason on freeze / unfreeze.
min_reason_length 5 Minimum reason length when required.
resource.enabled true Whether the central FreezeResource registers itself.
resource.navigation_group Compliance Navigation group for the resource.
resource.navigation_icon heroicon-o-lock-closed Navigation icon.
resource.navigation_sort 90 Navigation sort order.

#Events

Subscribe to these events for notifications, mirroring, or analytics:

  • Wezlo\FilamentRecordFreezer\Events\RecordFrozen — a record just became frozen.
  • Wezlo\FilamentRecordFreezer\Events\RecordUnfrozen — a record was just released.

Both carry the Freeze model (including the freezable morph target).

#Low-level engine API

use Wezlo\FilamentRecordFreezer\Services\FreezingEngine;

$engine = app(FreezingEngine::class);

$engine->freeze($contract, 'Audit hold', frozenBy: $userId);
$engine->unfreeze($contract, 'Released by CFO', unfrozenBy: $userId);

The engine performs no authorization of its own — it's a low-level primitive. Enforce permissions at the caller (action, job, command) before invoking it.

#Translations

The package ships full translations for:

  • en (English)
  • ar (Arabic)

Every user-visible string — action labels, modal headings, notifications, table columns, filters, tooltips — is routed through __('filament-record-freezer::freezer.…'). Switch locales via App::setLocale() or your existing locale middleware and the entire freezer UI follows.

Publish the translation files to override them in your host app:

php artisan vendor:publish --tag="filament-record-freezer-translations"

Developer-facing exception messages (RecordFrozenException, engine validation) stay in English intentionally — they go to logs and stack traces, not end users.

#Testing

The package is exercised by 27 Pest tests covering the trait, observer, engine, action group, resource, and re-freeze history semantics. Run them from the host app:

php artisan test --compact tests/Feature/FilamentRecordFreezer

The author

Mustafa Khaled avatar Author: Mustafa Khaled

15 Year Laravel Developer

Plugins
10
Stars
2

From the same author

Workspace Tabs plugin thumbnail

Workspace Tabs

Browser-like tabs for Filament panels. Open multiple pages in tabs without losing context, drag to reorder, pin frequently accessed pages, and right-click for quick actions.

Mustafa Khaled avatar Author: Mustafa Khaled
5 stars
Tag: Panels Tag: Kit More tags: +1
Dark mode ready Multilingual support
Free
Get it now
Search Spotlight plugin thumbnail

Search Spotlight

A full-screen Spotlight / command-palette search overlay for Filament panels. Opens on ⌘K (configurable), aggregates results from multiple categories (records, resources, pages, actions, plus recent/pinned from localStorage), and composes Filament's built-in `GlobalSearchProvider` — every resource that already implements `getGloballySearchableAttributes()` shows up automatically.

Mustafa Khaled avatar Author: Mustafa Khaled
1 star
Tag: Panels
Dark mode ready Multilingual support
Free
Get it now
Export Pro plugin thumbnail

Export Pro

A comprehensive, model-agnostic export engine for Filament. Handles large datasets with background processing and real-time progress tracking, maintains full export history with audit trails, supports scheduled/recurring exports with delivery, and gives admins visibility into who exported what and when.

Mustafa Khaled avatar Author: Mustafa Khaled
0 stars
Tag: Action Tag: Developer Tool More tags: +3
Dark mode ready Multilingual support
$99.00
Buy now
Record Watcher plugin thumbnail

Record Watcher

Subscribe to individual Eloquent records and receive in-panel Filament notifications whenever they change — with the actor (who) and a field-level diff (what). Watches can carry conditions ("only if status changes", "only if amount > 10K"), can be paused, and live on a personal **My Watches** page scoped to the authenticated user. Every fan-out is also persisted to a permanent event log, so users can review the full change history even after dismissing notifications.

Mustafa Khaled avatar Author: Mustafa Khaled
1 star
Tag: Action Tag: Developer Tool More tags: +2
Dark mode ready Multilingual support
Free
Get it now