sfolador/laravel-locked
sfolador/laravel-locked adds simple record locking to your Laravel Eloquent models, helping prevent conflicting concurrent edits. Lock records before modifying them so simultaneous requests can't overwrite each other's changes.
Install and wire it up (5 minutes)
composer require sfolador/laravel-locked
locked attribute on the model:Schema::table('invoices', function (Blueprint $table) {
$table->boolean('locked')->default(false)->index();
});
use Sfolador\LaravelLocked\Traits\Lockable;
class Invoice extends Model
{
use Lockable;
}
Where to look first
README.md — the trait namespace and the exact public API (lock(), unlock(), isLocked(), scopes, exception class) are the only things you need from it.src/Traits/Lockable.php — read it top to bottom; it's small, and it tells you exactly which Eloquent hooks are intercepted (saving/updating, deleting) and which are not.src/Exceptions/ — the exception thrown on a blocked write. You will be rendering this into HTTP responses.tests/ — the fastest way to confirm behaviour on your Laravel version (last release was Feb 2023, so verify against Laravel 10/11 before committing to it).First use case
The canonical one: an invoice/order/payroll record that becomes immutable once it reaches a terminal state. Instead of scattering if ($invoice->status === 'paid') abort(403) through controllers, you lock the row when it's finalized and let the model refuse writes:
$invoice->update(['status' => 'paid', 'paid_at' => now()]);
$invoice->lock();
From that point, $invoice->update([...]) and $invoice->delete() throw, no matter which controller, job, or console command tries it. That's the whole value proposition — a single, central guard that survives refactors.
Lock inside the state transition, not the controller. Put the lock() call in the same action/service that finalizes the record, wrapped in a transaction with the write it guards:
DB::transaction(function () use ($invoice) {
$invoice->update(['status' => 'paid', 'paid_at' => now()]);
$invoice->lock();
});
Controllers should not be responsible for locking; if a second entry point (queued job, artisan command, webhook) finalizes invoices, it must lock too.
Query scopes drive the UI. Use the package's scopes rather than where('locked', false) by hand, so a config change to the column name doesn't break you:
Invoice::unlocked()->get(); // editable records
Invoice::where('locked', true)->count();
Eager-load lists for admin screens and render a padlock badge from a single locked field exposed by your API resource.
Policies are the read side; the trait is the write side. The trait throws at persistence time; policies control what the UI offers. Combine them so users never see a save button that will 423:
public function update(User $user, Invoice $invoice): bool
{
return $user->can('manage-invoices') && ! $invoice->isLocked();
}
Handle the exception centrally. Catch it once and map to HTTP 423 (Locked) instead of try/catching in every controller. Laravel 11+:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(fn (ModelLockedException $e) => response()->json([
'message' => 'This record is locked and cannot be modified.',
], 423));
})
Older skeletons: register a renderable in app/Exceptions/Handler.php.
Deliberate overrides need to be explicit and audited. An admin "unlock" endpoint should be its own route, its own policy ability (unlock-invoice), and its own log entry — never a flag toggled by the generic update form:
Route::delete('/invoices/{invoice}/lock', UnlockInvoice::class)
->middleware('can:unlock,invoice');
Then $invoice->unlock() followed by the corrective edit, re-locking afterwards if the record should stay immutable.
Factory state and seeders. Add a locked() state so tests read clearly:
public function locked(): static
{
return $this->state(fn () => ['locked' => true]);
}
Queued work. A job dispatched before the lock may run after it. Check isLocked() at the top of the job and exit early (or release) rather than letting the exception burn a retry and land in failed_jobs.
Blade / Inertia. Guard the UI with @unless($invoice->isLocked()) around edit controls; it costs nothing and removes most user-facing exception noise.
locked must never be fillable. If it is, a crafted PUT /invoices/1 with locked=0 unlocks the record and defeats the entire package. Keep it out of $fillable and set it only via lock()/unlock().Invoice::where(...)->update([...]), DB::table('invoices')->update(...), ->delete() on a builder, and raw SQL all skip model events. The trait guards the model, not the table. Audit your codebase for these before claiming immutability.forceDelete() and some bulk paths may not be intercepted the way delete() is — verify in tests/ or by reading the trait. Soft-deleting a locked model is a distinct behaviour worth confirming explicitly.onDelete('cascade').->default(false), pre-existing rows are NULL, which is falsy but will surprise where('locked', false) queries. Backfill in the same migration.lockForUpdate() inside a transaction, then set the flag:DB::transaction(function () use ($id) {
$invoice = Invoice::lockForUpdate()->findOrFail($id);
// ... mutate, then lock()
});
app/Support/ — it's small enough that copying it and owning it is a legitimate choice for a 41-star dependency.'locked' => 'boolean' to $casts if the package doesn't already, so isLocked() returns a real bool and JSON responses serialize true/false rather than 1/0.$this->expectException(ModelLockedException::class) plus an assertDatabaseHas that the value did not change — the second assertion is what catches a silently-swallowed exception.lock() can dispatch a domain event (InvoiceLocked) or stamp locked_at/locked_by, which the package itself doesn't track. Do this in the model or a dedicated action if you need an audit trail.How can I help you explore Laravel packages today?