asignua / filament-record-lock
Pessimistic record locking for Filament 5: one editor per record, a heartbeat, a blocking notice, gated takeover and a lock manager. The maintained successor of kenepa/resource-lock.
Requires
- php: ^8.3
- filament/filament: ^5.11.2
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
One editor per record. When someone opens a record for editing, Record Lock holds it for them, renews the hold while the page is open, and gives it back when they save or leave. A second person who opens the same record gets a blocking notice that says who is editing and since when, with three ways out: back to the list, view it read-only, or - for people you allow - take it over. The person who lost the record is told on their next heartbeat, and a save from a page that no longer holds the lock is refused on the server, not just hidden in the browser.
It is the successor of kenepa/resource-lock, which stopped before Filament 5, with the same model trait, page traits and
plugin methods, so a project moves over by swapping a namespace (see Migrating from kenepa/resource-lock).
- Works with integer, UUID and ULID keys on any Eloquent model.
- A heartbeat renews the lock and notices a takeover; a lock that is not renewed expires on its own.
- Released on leave with
navigator.sendBeacon- it survives a closed tab - and by a prune command for the rest. - Closed by default: nobody may take a lock over or open the manager until you define a gate.
- A table column that marks locked rows, record and bulk actions to release locks, and a read-only "Record locks" manager.
- Edit pages and edit modals (
ManageRecords). - No JavaScript or CSS to build: the notice is Filament's own modal.
- English, Ukrainian, Polish, German, French, Spanish, Italian, Dutch, Brazilian Portuguese and Turkish.
Screenshots
Dark mode follows the panel:
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Filament 5
Installation
composer require asignua/filament-record-lock php artisan vendor:publish --tag=filament-record-lock-migrations php artisan migrate
Optionally publish the config (--tag=filament-record-lock-config).
Register the plugin on your panel:
use Asignua\FilamentRecordLock\RecordLockPlugin; $panel->plugin(RecordLockPlugin::make());
Usage
1. The model gets the trait:
use Asignua\FilamentRecordLock\Models\Concerns\HasLocks; class Article extends Model { use HasLocks; }
2. The Edit page gets one:
use Asignua\FilamentRecordLock\Resources\Pages\Concerns\UsesResourceLock; class EditArticle extends EditRecord { use UsesResourceLock; }
For a resource that edits in a modal on a single page (ManageRecords) use UsesSimpleResourceLock instead; it locks
the record while the table's edit action is open (override recordLockActionNames() to lock other actions).
That is the whole setup. Opening the page takes the lock; a second user sees the notice.
3. Allow some people to take a lock over (optional). Define the gate; nobody passes until you do:
Gate::define('unlockRecordLock', fn (User $user, ?Model $record = null): bool => $user->isAdmin());
4. Show the locks (optional):
use Asignua\FilamentRecordLock\Actions\ReleaseLockAction; use Asignua\FilamentRecordLock\Actions\ReleaseLocksBulkAction; use Asignua\FilamentRecordLock\Tables\Columns\RecordLockColumn; $table ->modifyQueryUsing(fn (Builder $query) => $query->withRecordLock()) // one query for the whole page ->columns([RecordLockColumn::make('lock'), /* ... */]) ->recordActions([ActionGroup::make([EditAction::make(), ReleaseLockAction::make()])]) ->toolbarActions([ReleaseLocksBulkAction::make()]);
The column shows a lock icon on rows somebody else is editing (a pencil on rows you are editing) and names the holder in the tooltip. The release actions are visible only to people who pass the unlock permission.
5. The manager. The plugin registers a read-only "Record locks" resource. It is closed until the viewRecordLocks gate
(or ->authorizeManager()) lets a user in. It lists active locks (filter for expired ones), releases one, many or all.
Model API
HasLocks keeps the method names of kenepa/resource-lock: lock(), unlock(force: false), isLocked(),
isUnlocked(), isLockedByCurrentUser(), lockCreatedByCurrentUser(), hasExpiredLock(), the resourceLock() relation
(also recordLock()), and a new withRecordLock() scope. For code of your own (a job, an API) use the service:
$locker = app(Asignua\FilamentRecordLock\Support\RecordLocker::class); $locker->acquire($record, $user); // ?RecordLock - the lock if $user holds it afterwards $locker->active($record); // ?RecordLock - whoever holds it $locker->release($record, $user); $locker->forceRelease($record); // administrative $locker->takeOver($record, $user); // fires RecordLockTakenOver
Asignua\FilamentRecordLock\Events\RecordLockTakenOver carries the record, the previous holder and the new one. The previous
holder's open page already shows a notice and a toast at its next heartbeat; listen to the event to also send a mail, a
database notification or a Slack message.
How it works
- Acquire.
mount()of the page inserts one row per record (a unique index on the morph pair makes the insert the tie-breaker when two people open the record in the same millisecond). A row that is expired, or already yours, is taken with one conditionalUPDATE. - Heartbeat. The page polls (
wire:poll, default every 15 s, only while it holds a record) and extendsexpires_atby the lock timeout (default 120 s). A closed laptop stops polling, the lock expires, the record is free. - Release on leave. Every page instance has a random holder token. On
pagehideand Livewire'slivewire:navigatingthe page sendsnavigator.sendBeacon()with the lock id, the holder token and the CSRF token toPOST /{panel}/record-lock/release. A beacon is the one request a browser still sends while the page is being torn down (a Livewire call would be cancelled), it goes through the panel's session and CSRF middleware, and the server only deletes a lock that belongs to that user and that page instance. The token also protects against the classic race of a reload: the old page's beacon arrives after the new page took the lock, finds another token, and does nothing. A save that redirects releases immediately, on the server. - Lost lock. If the lock expired and somebody took the record, the heartbeat shows the "taken over" notice. If it
expired and nobody took it, the heartbeat quietly takes it again - unless the record was saved by someone else in the
gap (compared by
updated_at), in which case the page asks the user to reload instead of letting them overwrite it. - Save check.
save()and inline field saves verify the lock first (check_locks_before_saving, on by default). A user who removed the notice with the browser tools still cannot save. - A blocked page waits. While the notice is up the page polls too; when the holder leaves, it reloads the record and continues by itself.
One user, several tabs: the lock belongs to the user, so a second tab is not blocked. The latest tab owns the release token; closing the older tab does not drop the lock, closing the newest does (the other tab's next heartbeat takes it back).
Configuration
Fluent methods on the plugin override config/filament-record-lock.php:
RecordLockPlugin::make() ->lockTimeout(120) // seconds without a heartbeat before a lock expires ->presencePollingInterval(15) // seconds between heartbeats; keep the timeout >= 2x the interval ->usesPollingToDetectPresence() // false: no heartbeat, the lock lives only `lockTimeout` seconds ->pollingKeepAlive() // wire:poll.keep-alive - keep polling in background tabs ->pollingVisible() // wire:poll.visible ->checkLocksBeforeSaving() // refuse a save from a page that lost the lock ->displayResourceLockOwner() // show who holds it in the notice and the tooltip ->ownerUsing(fn (User $user) => $user->name.' ('.$user->team->name.')') ->unlockerGate('unlockRecordLock') // the ability that allows taking over / releasing ->authorizeUnlock(fn (?Model $record, User $user) => $user->can('update', $record)) ->gate('viewRecordLocks') // the ability that opens the manager ->authorizeManager(fn (User $user) => $user->isAdmin()) ->manager(false) // do not register the manager at all ->navigationGroup('System')->navigationIcon('heroicon-o-lock-closed')->navigationSort(90) ->navigationLabel('Locks')->pluralLabel('Locks')->navigationBadge()->registerNavigation(false) ->userModel(App\Models\User::class) // default: the model of the panel's auth guard ->resourceLockModel(App\Models\MyLock::class); // must extend RecordLock
The config file also has table / connection (the migration follows them), lock_notice.display_owner, and
schedule (record-lock:prune every 10 minutes; enabled => false to schedule it yourself). Expired locks never block
anybody, pruning only keeps the table small.
Real-time (optional)
The heartbeat renews the lock and stays exactly as it is - a socket cannot replace it. With broadcasting switched on, the plugin additionally pushes "this record's lock was released / taken over", so a waiting page (or the page that just lost its lock) reacts at once instead of at its next poll (up to 15 s).
RECORD_LOCK_REALTIME |
Behaviour |
|---|---|
false |
no events, no listeners - behaviour identical to v1.0 |
true |
push on release and takeover |
unset (null) |
on when broadcasting.default is reverb, pusher or ably |
BROADCAST_CONNECTION=reverb RECORD_LOCK_REALTIME=true RECORD_LOCK_BROADCAST_CONNECTION= # optional: another connection than the default one
- Event.
Events\RecordLockChanged(ShouldBroadcastNow+ShouldDispatchAfterCommit, no queue worker needed) is dispatched next to the existingRecordLockTakenOver, which is untouched. It fires when a lock row is actually removed (page save/leave, the leave beacon,unlock(), "Release lock" in the manager) and on a takeover from another holder. Locks that merely expire (record-lock:prune) stay silent: the waiting page's heartbeat still notices them. - Payload.
type(morph class),key,reason(released/taken_over) andholder(user id, or null). The page does not trust it: it just runs the samerecordLockHeartbeatit polls with, so all takeover / reload logic stays in one place. - Channel. A private channel per record,
filament-record-lock.{type}.{key}; both segments are base64url so any class name or key is a valid channel name. Authorisation is registered by the plugin: the model must useHasLocksand the user must be allowed to edit it -Resource::canEdit()of the panel that has the plugin, or the model'supdatepolicy when no resource edits it. Unknown types and missing records are refused./broadcasting/authis registered with the panel's guard when the app has not (realtime.register_auth_route). - Subscription. The lock notice (observer view) subscribes with
window.Echo.private(...)while this page holds - or waits for - a record, and unsubscribes when the modal closes or the page is left. It does this from JavaScript, not through a Livewireecho-private:listener: Livewire registers listeners only when a component is mounted, and a modal-edit page (UsesSimpleResourceLock) has no record - and no lock - at mount.
Echo: who loads it
realtime.echo (RECORD_LOCK_ECHO) - the same keys, semantics and default as in filament-chat and filament-kanban:
RECORD_LOCK_ECHO |
Meaning |
|---|---|
plugin (default) |
the plugin brings Echo itself, reusing the Echo + pusher-js that Filament already ships (window.EchoFactory), configured from the broadcasting connection and realtime.client. Nothing else to install. |
filament |
Filament's own filament.broadcasting.echo config |
host |
your app already loads window.Echo (your Vite bundle) |
The Echo script is rendered (panel SCRIPTS_AFTER hook) only when real time is on, the mode is plugin, the user is
signed in and the panel has this plugin. Echo is initialised once per page: the script returns at once if
window.Echo already exists, so installing this plugin together with filament-chat and/or filament-kanban
(each in plugin mode) is safe - the first one to run creates the instance, the others reuse it. Filament's
filament.broadcasting.echo and a host Echo are respected the same way.
php artisan install:broadcasting --reverb # Reverb + the Echo packages; not needed for plugin mode itself
php artisan reverb:start
BROADCAST_CONNECTION=reverb RECORD_LOCK_REALTIME=true # where the BROWSER connects, when it differs from where PHP publishes RECORD_LOCK_WS_HOST=ws.example.com # empty = the page's host RECORD_LOCK_WS_PORT=8080 # empty = 443 on https, 80 on http RECORD_LOCK_WS_SCHEME=https # empty = the page's protocol
Where the browser connects (shared by all asign plugins). Each field (host, port, scheme) is resolved on its own, first set value wins:
RECORD_LOCK_WS_HOST/_PORT/_SCHEME(this plugin only, the lines above);- Laravel's browser-side variables
VITE_REVERB_HOST/VITE_REVERB_PORT/VITE_REVERB_SCHEME(written byphp artisan install:broadcasting --reverb); - Filament's own Echo config,
filament.broadcasting.echo(wsHost,wsPort/wssPort,forceTLS); - the page's own host, protocol and default port.
Whichever plugin renders its hook first creates window.Echo for the whole page, so a plugin without its own address must not
fall back to localhost: set VITE_REVERB_* once and every asign plugin on the site (chat, kanban, record lock) connects to the same place.
-
Local, no Docker:
reverb:starton 8080 ->RECORD_LOCK_WS_PORT=8080. -
Docker / Sail: PHP publishes to the service (
REVERB_HOST=reverb), the browser to the published port. -
Production behind nginx: proxy the websocket on the same host and leave the three variables empty:
location ^~ /app/ { proxy_pass http://reverb:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 60s; }
Pusher Channels / Ably-compatible connections work the same way (BROADCAST_CONNECTION=pusher; the cluster is read
from the connection's options.cluster). Without a working socket nothing breaks: the heartbeat still runs.
Gates
| Ability | Default | Decides |
|---|---|---|
unlockRecordLock ($user, ?$record) |
nobody | "Unlock and edit" in the notice, ReleaseLockAction, the bulk action, "Release all" |
viewRecordLocks ($user) |
nobody | the "Record locks" manager |
An undefined ability means "no", never "yes". ->authorizeUnlock() / ->authorizeManager() take a bool or a closure and
win over the gate. The server checks the permission again when "Unlock and edit" is pressed; hiding the button is only a courtesy.
Gotchas
- Run the migration. The table (
record_locks) is published, not auto-loaded:vendor:publish --tag=filament-record-lock-migrations. - Timeout vs. polling. With a 120 s timeout and a 15 s heartbeat there is room for seven missed beats. Setting the timeout below the interval makes locks expire between beats while the user is typing.
- Background tabs. Livewire slows polling in hidden tabs; the lock may expire and be taken while the user is on another
tab. That is the intended meaning of "idle". Use
pollingKeepAlive()if you want to hold a record from a background tab. - Eager-load for the column.
RecordLockColumnasks the database per row unless the query has->withRecordLock(). - Actions are guarded too. On an Edit page every mounted action (Delete, Replicate, your own, actions inside the form)
is refused like a save when the lock is lost; list read-only ones in
recordLockAllowedActions(). Relation managers are separate Livewire components and are not covered - protect their writes with the model policy. - Tenant panels. Locks are not tied to a tenant, so the "Record locks" manager stays shut on a panel with tenancy until you
narrow it:
->scopeManagerQuery(fn (Builder $query) => $query->whereIn('user_id', ...))(also applied to "Release all"). - Lock state is
#[Locked]on the page; the browser cannot flip it. Keys are bound as strings when eager loading (withRecordLock()works on PostgreSQL with integer keys). - Your own
mount()/save()on a page that uses the trait must callparent::mount()/parent::save(). - Custom pages that are not resource pages can use
UsesLocksyourself: implementrecordLockRecord()andrecordLockRefresh(), callinitializeRecordLock()when the record is opened. - Unsaved changes of a user who lost the lock stay in their browser until they reload; the notice says so.
Migrating from kenepa/resource-lock
-
composer remove kenepa/resource-lock && composer require asignua/filament-record-lock. -
Swap the namespaces:
kenepa/resource-lock asignua/filament-record-lock Kenepa\ResourceLock\ResourceLockPluginAsignua\FilamentRecordLock\RecordLockPluginKenepa\ResourceLock\Models\Concerns\HasLocksAsignua\FilamentRecordLock\Models\Concerns\HasLocksKenepa\ResourceLock\Resources\Pages\Concerns\UsesResourceLockAsignua\FilamentRecordLock\Resources\Pages\Concerns\UsesResourceLockKenepa\ResourceLock\Resources\Pages\Concerns\UsesSimpleResourceLockAsignua\FilamentRecordLock\Resources\Pages\Concerns\UsesSimpleResourceLockKenepa\ResourceLock\Models\ResourceLockAsignua\FilamentRecordLock\Models\RecordLock -
Drop the old table and run ours; locks are short-lived, nothing needs converting:
Schema::dropIfExists('resource_locks'), then publish and run the new migration. -
Delete the published
config/resource-lock.php, publish ours if you want to change anything, and replace the->plugin(ResourceLockPlugin::make()...)call. -
Define the gates (see below) - this is the one change that can surprise you.
What stays the same: the model methods (lock, unlock, isLocked, isUnlocked, isLockedByCurrentUser,
lockCreatedByCurrentUser, hasExpiredLock, resourceLock()), the page traits and their names, lockTimeout() in
seconds, checkLocksBeforeSaving(), usesPollingToDetectPresence(), presencePollingInterval(), pollingKeepAlive(),
pollingVisible(), displayResourceLockOwner(), unlockerLimitedAccess(), unlockerGate(),
limitedAccessToResourceLockManager(), gate(), userModel(), resourceLockModel(), resourceClass(),
resourceLockOwnerAction() (a class with execute($user)), and the navigation methods (navigationBadge, navigationIcon,
navigationLabel, pluralLabel, navigationGroup, navigationSort, registerNavigation).
What differs:
| kenepa/resource-lock | here | |
|---|---|---|
| Table | resource_locks, user_id foreign key, morphs('lockable') |
record_locks (configurable), string keys (int/UUID/ULID), no foreign keys, unique per record, expires_at, holder |
| Expiry | updated_at + timeout |
explicit expires_at, extended by the heartbeat |
| Polling | off unless usesPollingToDetectPresence() |
on by default (a heartbeat is what makes the timeout meaningful) |
| Default timeout | 600 s | 120 s |
| Unlocker | everyone, unless unlockerLimitedAccess() |
nobody, unless the gate allows (unlockerLimitedAccess(false) restores "everyone") |
| Manager | open to every panel user, unless limited | closed until viewRecordLocks / authorizeManager() (limitedAccessToResourceLockManager(false) opens it) |
| Release on leave | Livewire call in onbeforeunload (often cancelled by the browser) |
sendBeacon to a panel route, guarded by user + page-instance token |
| Tab race | the lock belongs to the user | same, plus a per-page token so an old page cannot drop a newer page's lock |
| Taken-over holder | next save fails | notified at the next heartbeat; save refused; stale-write detection |
| Notice | Livewire observer component in PAGE_START, custom Blade |
Filament modal, driven by the page's own properties; works on modal-edit pages |
| Spatie permission option | a permission name as an alternative to the gate | not built in - call $user->can() in authorizeUnlock() |
| Extras | RecordLockColumn, ReleaseLockAction, ReleaseLocksBulkAction, RecordLockTakenOver event, record-lock:prune, withRecordLock() scope |
|
| Not ported | UsesResourceLock::resourceLockReturnUrl() override |
override recordLockReturnUrl() on the page instead |
| Not ported | views publish tag, get_resource_lock_owner_action config key |
the notice is a Filament modal (publish the translations to change text); use ownerUsing() or resourceLockOwnerAction() on the plugin |
Translations
The interface ships in English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish
under the filament-record-lock::filament-record-lock namespace. A test keeps every language in step with the English keys
and placeholders. Override a string by publishing the translations (--tag=filament-record-lock-translations) and editing the
copy in lang/vendor/filament-record-lock.
Try it by hand
The repository has a workbench with an Articles resource (edit and view pages), a Notes resource (UUID keys, modal edit)
and two users. Open it in two browsers, or one normal and one private window:
composer install
vendor/bin/testbench workbench:build
vendor/bin/testbench filament:assets
vendor/bin/testbench db:seed --class="Workbench\Database\Seeders\DatabaseSeeder"
vendor/bin/testbench serve --host=0.0.0.0
Log in at /admin as emma@example.com / password (may unlock and opens "Record locks") in one window and
noah@example.com / password (an ordinary editor) in the other, then open the same article's edit page in both.
AI agents
The package ships Laravel Boost guidelines (resources/boost/guidelines/core.blade.php) so a
coding agent wires it up correctly.
Testing
composer install vendor/bin/phpunit vendor/bin/phpstan analyse --memory-limit=1G vendor/bin/pint --test
Changelog
See CHANGELOG.md.
More plugins by asign
- Kanban — drag-and-drop boards for Eloquent models
- Kanban Pro — swimlanes, WIP limits and workflow rules for boards
- Activity Log Plus — audit trail and history for models
- Themes — per-user and per-panel colour themes
All plugins: https://filamentphp.com/plugins?q=asign
License
The MIT License (MIT). See LICENSE.md.




