Laravel: Optimistic Locking in one line

Timestamps are our saviors

4 min readDec 16, 2024

--

Photo by FlyD on Unsplash

Laravel supports Pessimistic Locking out of the box. In other words, the application can ask the database to “lock” some rows from any change until an update on those records occur. That’s great, until you get deadlocks for not wrapping them into a transaction.

Sometimes, Pessimistic Locking just works, but it’s not a silver bullet. Imagine someone trying to edit a Post, only to see its dozens of paragraphs gone because the head editor updated the text just before the user did.

That problem can be solved with some Optimistic Locking.

Luckily for us, Laravel’s Eloquent ORM has all the necessary tools to implement Optimistic Locking without having to download third party packages or even change our Eloquent Models to a point of becoming a hindrance to maintain.

The problematic, and the solution

Let’s imagine we are a “collaborator” on a news site, and we are actively writing a “Post”. At the same time, the head editor is also editing the same Post on its own browser. The head editor retrieves the Post from the server at 14:50, but we started to after 15:00 and finished at 15:40.

As you can guess, if the editor changes one letter and presses the “Update” after we finished, all of our changes will be overwritten for what the Editor has in its browser.

Please, don’t think too much about example, is just for illustration. You should save the changes separately so these can be recovered later, and offer a difference between the changes and the data in the database, if possible.

You may often see on the internet some Optimistic Locking examples using a version number, or even a hash, but in Laravel we already have something we can use, out of the box. Every time an Eloquent Model gets updated in the database, the updated_at column gets updated with the current timestamp — as long you have not removed it from the model declaration.

echo $post->updated_at; // 2024-01-01 15:50:00

$post->update(['title' => 'I altered the deal']);

echo $post->updated_at; // 2024-12-12 16:30:54

In other words, if the updated_at timestamp has changed (by becoming more recent), then we’re dealing with stale data. This is the key, since Optimistic Locking requires a way to check if the record data changed between two points in time.

In our application, we want to retrieve the model using Route Model Binding, but also the timestamp which we can later compare to the model. If the timestamp of the data we retrieved is equal to the column in the database, then we can safely update the model since nobody did it previously.

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::patch('post/{post}', function (Post $post, Request $request) {
// Validate the request having a UNIX Epoch timestamp ("U" format)
$request->validate([
'updated_at' => 'required|timestamp:U'
]);

// Check the post has the same timestamp. If not, it was
// already updated elsewhere and the data may be stale.
if ($post->updated_at->notEqual($request->int('timestamp')) {
return back()->withErrors([
'save' => 'The Post changed before updating it. Refreshing'
])->withInput(['body', 'text']);
}

// Validate the request data to fill in the post.
$validated = $request->validate([
// ...
]);

// Save the model with the validated data.
$post->update($validated);

return back();
});

This may suffice for small applications. While plausible, it will be difficult to see two or more users trying to update the same resource.

Savvy developers will note that, for a resource being constantly modified by more than one application instance, there is a slight window between the check and the update where stale data can overwrite the newer data. It’s not the end of the world; we can fix this.

With a little more code, we can make an Optimistic Lock by doing a a direct update to the row on the database with in-query check in one statement. We only need to add a WHERE clause where the updated_at column matches what we are receiving as part of the request.

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::patch('post/{post}', function (Post $post, Request $request) {
// Validate the request having a UNIX Epoch timestamp
$request->validate([
'updated_at' => 'required|timestamp:s'
]);

// Validate the data from the request.
$validated = $request->validate([
// ...
])

// Update the post, and return how many rows were affected.
$updated = Post::whereKey($post->id)
->where('updated_at', $request->date('updated_at'))
->update($validated);

// If there was 0 rows affected, then the post was previously updated.
if (!$updated) {
return back()->withErrors([
'save' => 'The Post changed before updating it.'
])->withInput(['body', 'text']);
}

return back();
}

The above example won’t fire any Eloquent Event, which is the tradeoff unless you want to fire it manually. That can be done easily using the event() function helper, just after updating the model.

// Fire the "updated" event with the fresh updated post.
event("eloquent.updated: ".Post::class, $post->fresh());

Clearly this is great alternative to using Pessimistic Locking inside a Database Transaction, because there is still a (tiny) window of time where the database row can change in regard of the data received.

--

--

Italo Baeza Cabrera
Italo Baeza Cabrera

Written by Italo Baeza Cabrera

Graphic Designer graduate. Full Stack Web Developer. Retired Tech & Gaming Editor. https://italobc.com

No responses yet