Pessimistic Locking in Laravel Eloquent with refreshForUpdate()
Laravel Tips & Tricks #Laravel #Eloquent #Pessimistic Locking #Database Transactions #Laravel 13

Pessimistic Locking in Laravel Eloquent with refreshForUpdate()

3 min read Mohamed Said Mohamed Said

What Is refreshForUpdate()?

Laravel has long provided two separate tools for working with database rows inside transactions:

  • refresh() — reloads a model's attributes from the database.
  • lockForUpdate() — appends FOR UPDATE to a query, preventing other transactions from reading or modifying the row until the lock is released.

What was missing was a way to do both on an existing model instance without rebuilding the query manually. Laravel 13.27 fills that gap with refreshForUpdate(), contributed by @stevebauman in #61247.


How It Works Under the Hood

The implementation is concise:

public function refreshForUpdate()
{
    if (! $this->exists) {
        return $this;
    }

    return $this->refreshUsingQuery(
        $this->newQueryWithoutScopes()->lockForUpdate()
    );
}

It delegates to refreshUsingQuery() — the same internal helper that powers refresh() — but passes a query that already has lockForUpdate() applied. That helper:

  1. Scopes the query to the model's primary key.
  2. Routes the query through useWritePdo() so a read replica cannot serve a lock you are about to depend on.
  3. Calls firstOrFail() to fetch the row.
  4. Replaces the model's raw attributes in place.
  5. Reloads any relations that were already eager-loaded.
  6. Syncs the original attribute state.

The only addition refreshForUpdate() makes over a plain refresh() is the FOR UPDATE lock.


Before and After

Before Laravel 13.27

To safely decrement stock while preventing concurrent overselling, you had to re-query the model explicitly inside the transaction:

public function purchase(Product $product): Response
{
    DB::transaction(function () use ($product) {
        $product = Product::query()
            ->lockForUpdate()
            ->findOrFail($product->getKey());

        if ($product->stock === 0) {
            throw new RuntimeException('The product is out of stock.');
        }

        $product->decrement('stock');
    });

    // ...
}

This works, but it requires you to reassign $product and remember to call lockForUpdate() every time.

After Laravel 13.27

With refreshForUpdate() you call the method directly on the model instance:

public function purchase(Product $product): Response
{
    DB::transaction(function () use ($product) {
        $product->refreshForUpdate();

        if ($product->stock === 0) {
            throw new RuntimeException('The product is out of stock.');
        }

        $product->decrement('stock');
    });

    // ...
}

The model is refreshed with the latest database values and locked for the duration of the transaction — in one readable line.


Key Takeaways

  • refreshForUpdate() is available from Laravel 13.27 onward.
  • It combines refresh() and lockForUpdate() into a single Eloquent method call.
  • The query is always routed to the write PDO connection, preventing stale reads from a replica.
  • If the model does not exist ($this->exists === false), the method returns early without querying.
  • It must be called inside a DB::transaction() block; a FOR UPDATE lock has no effect outside a transaction.
  • Already-loaded Eloquent relations are refreshed automatically alongside the model's own attributes.

Source: Pessimistic Locking in Laravel Eloquent with refreshForUpdate() — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does refreshForUpdate() work outside a database transaction?
No. The FOR UPDATE lock it applies is a database-level construct that only holds for the duration of an open transaction. Calling refreshForUpdate() outside a DB::transaction() block will still execute the query, but the lock will be released immediately and provide no concurrency protection.
Q02 Will refreshForUpdate() reload eager-loaded relationships?
Yes. It delegates to the same refreshUsingQuery() helper used by refresh(), which reloads any relations that were already loaded on the model instance before the call.
Q03 What happens if the model does not exist in the database?
If $this->exists is false, refreshForUpdate() returns the model instance immediately without issuing any query, so it is safe to call even on unsaved models.

Continue reading

More Articles

View all