blendbyte/filament-resource-lock
blendbyte/filament-resource-lock adds record locking to Filament resources, preventing users from editing the same record simultaneously. It shows lock ownership, blocks conflicting edits, and releases locks automatically, helping teams avoid overwritten changes.
The package adds pessimistic "record is being edited" locking to Filament resources so two admins can't silently overwrite each other.
Install and publish.
composer require blendbyte/filament-resource-lock
php artisan vendor:publish --tag="filament-resource-lock-migrations"
php artisan vendor:publish --tag="filament-resource-lock-config"
php artisan migrate
This gives you a resource_locks table (the source of truth) and a config file controlling TTL/cleanup behaviour.
Opt models in. Add the package's lockable trait to every model that backs a Filament resource you want protected (e.g. Post, Order). The trait wires up the lock relation and the isLocked() / lock() / unlock() helpers.
Register the panel plugin. In your PanelProvider::panel() chain:
->plugin(ResourceLockPlugin::make())
Confirm the exact class import against the README — it lives under Blendbyte\FilamentResourceLock.
First use case to validate: open the same Edit page in two browser sessions. User A should acquire the lock on mount; User B should see the lock notice and find the form read-only (or be blocked outright, depending on config). Close A's tab and confirm B can take over after the TTL expires.
Look first at the published config (lock duration, cleanup cadence, whether the UI blocks or only warns) and at the trait — it's the smallest surface area and tells you what's hookable.
Invoice but leave low-risk Tag resources unlocked. Keep a checklist of resources where locking is required so new resources don't silently ship unprotected.isLocked() is the primitive for anything non-UI: policy checks, custom Filament actions, API controllers that mutate the same records. Combine with abort_unless() or a Filament ->visible(fn ($record) => ! $record->isLocked()) on destructive actions.Gate::allows('forceUnlockResource'), calling $record->unlock() and logging who broke the lock. Pairs well with an audit/activity package.TextColumn::make('lock')/icon column driven by the relation so list views surface which rows are currently being edited. Keep it toggleable() — it costs a relation load per row.isLocked(). If you were relying on the old behaviour (or worked around it with manual where clauses), drop those workarounds.lockForUpdate() — don't attempt to respect resource_locks from a queue worker.$record->unlock(); $record->isLocked(); could still return true because the relation was cached. On older versions, call $record->refresh() or unsetRelation() after unlocking — and still do so if you hold a different instance of the same model than the one that unlocked.isLocked() was previously an unscoped exists(). Before 2.2.1 it could return true based on any row in the locks table rather than the specific record. Symptom: every record appeared locked once one lock existed. If you're on ≤2.2.0 and see this, that's the bug — upgrade rather than patching around it.afterSave()/afterCreate() hooks or a mount()/booted() cleanup, otherwise users see "locked" on a record nobody is editing.php artisan migrate on deploy produces confusing "nothing locks" behaviour rather than an error. Add it to your deploy checklist.RefreshDatabase and Carbon::setTestNow() to exercise TTL expiry deterministically. Assert both directions: assertTrue($a->isLocked()) for a locked record and assertFalse() for a sibling record of the same model — the second assertion is what would have caught the 2.2.0 scoping bug.isLocked()/unlock(), so patched copies will drift fast.How can I help you explore Laravel packages today?