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
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
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.
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
Custom Fields
Eliminate custom field migrations forever. Let your users create and manage form fields directly in Filament admin panels with 20+ built-in field types, validation, and zero database changes.
Relaticle
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