wezlo / filament-record-freezer
Freeze Eloquent records against modification — audit holds, finalized contracts, legal holds — for Filament.
Package info
github.com/mustafakhaleddev/filament-record-freezer
pkg:composer/wezlo/filament-record-freezer
Requires
- php: ^8.2
- filament/filament: ^4.0 || ^5.0
- spatie/laravel-package-tools: ^1.0
Requires (Dev)
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-livewire: ^3.0
This package is auto-updated.
Last update: 2026-05-11 16:21:35 UTC
README
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