Record Freezer
Freeze individual Eloquent records against modification — finalised contracts, audited financial periods, legal holds
Author:
Mustafa Khaled
Documentation
- Requirements
- Installation
- Make a model freezable
- Filament integration — FreezableActionGroup
- Audit trail — who froze / unfroze
- Central admin resource
- Configuration reference
- Events
- Low-level engine API
- Translations
- Testing
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) ordelete()it throws aRecordFrozenException— from the UI, a job, a policy, ortinker. - Filament resources using the
HandlesFrozenRecordstrait have theircanEdit()/canDelete()disabled automatically, and row-level edit/delete actions are hidden viaFreezableActionGroup. - 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
From the same author
Modal Notifications
Render any Filament notification as a blocking modal by chaining one method: ->asModal(). Multiple modal notifications fired in the same request are queued one at a time — the user dismisses one and the next slides in, no stacking.
Author:
Mustafa Khaled
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.
Author:
Mustafa Khaled
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.
Author:
Mustafa Khaled
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.
Author:
Mustafa Khaled
Featured Plugins
A selection of plugins curated by the Filament team
Custom Dashboards
Let your users build and share their own dashboards with a drag-and-drop interface. Define your data sources in PHP and let them do the rest.
Filament
Data Lens
Advanced Data Visualization for Laravel Filament - a premium reporting solution enabling custom column creation, sophisticated filtering, and enterprise-grade data insights within admin panels.
Padmission
Spotlight Pro
Browse your Filament Panel with ease. Filament Spotlight Pro adds a Spotlight/Raycast like Command Palette to your Filament Panel.
Dennis Koch