Record Freezer
CommunityFreeze individual Eloquent records against modification — finalised contracts, audited financial periods, legal holds
Author:
Mustafa Khaled
Package health
BetaAutomated checks of this plugin's Composer package
15 checks
- Skipped: GitHub Actions pinned to SHA
- Skipped: GitLab CI includes pinned to SHA
- Passed: Open security advisories
- Passed: Dependabot PR responsiveness — No open Dependabot PRs.
- Skipped: Renovate MR responsiveness
- Skipped: Dependabot or Renovate configured
- Skipped: Dependency update cooldown configured
- Failed: Provides a security policy — View details on Plumb
- Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
- Passed: Commit and release recency — Active: last commit 106 days ago; last release 106 days ago.
-
Passed:
composer.lock not committed by library
—
composer.lockis absent from the released dist archive. - Passed: Dist archive is lean
-
Passed:
Current Laravel version supported
—
Package dependencies resolve together with current Laravel
13.0. -
Passed:
Current PHP version supported
—
Constraint
^8.2supports current PHP8.5. - Skipped: Current Symfony version supported
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
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
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
Lookups
Hierarchical lookup management for Filament with configurable multi-tenant support. Manage lookup tables (countries, categories, regions, statuses, etc.) with optional parent-child hierarchies. Each lookup type is defined as a PHP class with full control over permissions and behavior.
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
Advanced Tables (formerly Filter Sets)
Supercharge your tables with powerful features like user-customizable views, quick filters, multi-column sorting, advanced table searching, convenient view management, and more. Compatible with Resource Panel Tables, Relation Managers, Table Widgets, and Table Builder!
Kenneth Sese