Laravel refreshForUpdate(): lock an existing model in a transaction
- 21 Sept 2026
- 2 min read
- Technical
Laravel’s refreshForUpdate() has already become a staple for me. I’ve been using it regularly in project work and replacing the longer lockForUpdate() queries I previously wrote to lock models I already had.
Added in Laravel 13.27.0, it refreshes an existing Eloquent model and acquires a pessimistic database lock in one method call. It’s a small addition that makes the intent much clearer.
The same transaction, less code
Suppose you already have an $order, perhaps from route model binding, and want to cancel it only if it’s still pending. Its attributes could have changed since you loaded it, so you need to read its current state and lock the row before making that decision.
Previously, I’d write something like this:
use App\Models\Order;
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order) {
$order = Order::query()
->lockForUpdate()
->findOrFail($order->getKey());
if ($order->status !== 'pending') {
throw new RuntimeException('Only pending orders can be cancelled.');
}
$order->update(['status' => 'cancelled']);
});
Now, I can write:
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order) {
$order->refreshForUpdate();
if ($order->status !== 'pending') {
throw new RuntimeException('Only pending orders can be cancelled.');
}
$order->update(['status' => 'cancelled']);
});
The state check still happens after acquiring the lock. On a database that supports this row locking, competing updates must wait until the transaction commits or rolls back. Keep the refresh, check and update inside a transaction on the model’s database connection.
What happens underneath
The implementation builds a query with newQueryWithoutScopes()->lockForUpdate() and passes it to refreshUsingQuery(), the same helper used by refresh().
That helper looks up the model by its primary key using the write connection, calls firstOrFail(), and replaces the attributes on the existing object. It also reloads previously loaded relationships, excluding pivot instances, and synchronises the original attribute state. Those relationship queries don’t automatically inherit the row lock.
There’s still a database fetch. The saving is the query code and reassignment: you keep working with the same model instance.
Like refresh(), it overwrites unsaved attribute changes and bypasses global scopes. That last detail matters when replacing a scoped query, especially one enforcing tenant boundaries. It also returns unchanged for an unsaved model, so use it with a persisted record.
I still reach for lockForUpdate() when building the initial query. When I already have the model, refreshForUpdate() says exactly what I need. It’s a welcome addition to the framework, and one I’m already getting plenty of use out of.
