Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Locked Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up (5 minutes)

composer require sfolador/laravel-locked
  1. Add a boolean column to the tables you want to protect — the package keys off a locked attribute on the model:
Schema::table('invoices', function (Blueprint $table) {
    $table->boolean('locked')->default(false)->index();
});
  1. Apply the trait to the model:
use Sfolador\LaravelLocked\Traits\Lockable;

class Invoice extends Model
{
    use Lockable;
}
  1. Make sure the column is not mass-assignable (see Gotchas) and, if you publish a config file, check the column/behaviour names there.

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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().
  • Query-builder writes bypass everything. 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.
  • Cascade deletes. Deleting a parent that cascades at the DB level will remove locked children without an exception. If child records matter, don't rely on onDelete('cascade').
  • Existing rows and nulls. If you add the column without ->default(false), pre-existing rows are NULL, which is falsy but will surprise where('locked', false) queries. Backfill in the same migration.
  • Race conditions are not solved. Two concurrent requests can both read "unlocked" and both write; the lock only blocks writes that start after the flag is committed. For genuine mutual exclusion use lockForUpdate() inside a transaction, then set the flag:
DB::transaction(function () use ($id) {
    $invoice = Invoice::lockForUpdate()->findOrFail($id);
    // ... mutate, then lock()
});
  • Compatibility risk. Last release was Feb 2023. Before adopting, run the package's tests against your Laravel/PHP version, or vendor the trait into app/Support/ — it's small enough that copying it and owning it is a legitimate choice for a 41-star dependency.
  • Casting. Add '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.
  • Tests should assert the exception, not just the state. $this->expectException(ModelLockedException::class) plus an assertDatabaseHas that the value did not change — the second assertion is what catches a silently-swallowed exception.
  • Extension points. The trait's methods are public and overridable: subclass-level 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core