Preventing Duplicate Form Submissions Using Atomic Locks
November 7, 2023 • 7 min read
Duplicate form submissions or requests can be a common issue in web applications, often leading to unintended consequences. Laravel offers a straightforward solution to prevent these duplicates by using atomic locks. In this blog post, we will dig into the implementation of atomic locks to ensure that a form submission is processed only once. Furthermore, we will also explore how atomic locks can prevent the dispatching of the same job multiple times.
Atomic locks allow for the manipulation of distributed locks without worrying about race conditions.
Consider a scenario where users can initiate payments to other users using a form. If a user submits the form multiple times, we want to ensure that only the first request is processed while subsequent requests are ignored. Given that these transactions involve monetary value, processing the request multiple times could result in unintended multiple charges to the user.
Let’s take a look at an example controller, SendPaymentController
:
<?php
namespace App\Http\Controllers;
use App\Models\Account;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SendPaymentController
{
public function __invoke(Request $request)
{
/* validate the request */
$account = $request->user()->accounts()->findOrFail($request->input('account'));
$recipient = Account::findOrFail($request->input('recipient'));
$amount = $request->input('amount');
/* process the request */
return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
}
As you can see in the SendPaymentController
code above, the current implementation solely focuses on request validation and processing, which is working fine. However, without additional measures, submitting the form multiple times will naturally lead to the request being processed multiple times. This behavior is expected since no prevention mechanism is in place. Let’s explore how we can address this by implementing atomic locks.
To create an atomic lock, we use the Cache::lock
method, which accepts three arguments:
name
: This is the lock’s name. It’s crucial to use a unique name for each lock to prevent collisions and ensure their intended purpose.
seconds
: This argument specifies the duration for which the lock should remain valid.
Additionally, the Cache::lock
method offers an optional third argument, owner
, which we will discuss later in this post.
// SendPaymentController
public function __invoke(Request $request)
{
/* validate the request */
$account = $request->user()->accounts()->findOrFail($request->input('account'));
$recipient = Account::findOrFail($request->input('recipient'));
$amount = $request->input('amount');
$lock = Cache::lock($account->id.':payment:send', 10);
if (! $lock->get()) {
return to_route('payments.create')
->with('status', [
'type' => 'error',
'message' => 'There was a problem processing your request.',
]);
}
/* process the request */
return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
In the above code, we are creating a lock with the name {$account->id}:payment:send
which will be valid for 10 seconds. If the lock is acquired, we will process the request and redirect the user back to the form with a success message. If the lock is not acquired, we will redirect the user back to the form with an error message.
The error message is optional, you can just redirect the user back to the form without any message but for the sake of this example, we are showing an error message.
🎉 That’s it! we have now implemented atomic locks to prevent duplicate form submissions.
Preventing jobs from being dispatched multiple times
Let’s look at another example where we can use atomic locks to prevent jobs from being dispatched multiple times.
We can use the example from the previous section and move the code that processes the request to a job and dispatch it.
The SendPaymentController
controller might look like this:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPayment;
use App\Models\Account;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SendPaymentController
{
public function __invoke(Request $request)
{
/* validate the request */
$account = $request->user()->accounts()->findOrFail($request->input('account'));
$recipient = Account::findOrFail($request->input('recipient'));
$amount = $request->input('amount');
dispatch(new ProcessPayment($account, $recipient, $amount));
return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
}
In the provided code, we are dispatching a ProcessPayment
job to process the request. The same problem we had in the previous section applies here too. If the user submits the form multiple times, the job will be dispatched multiple times. Let’s see how we can prevent it.
// SendPaymentController
public function __invoke(Request $request)
{
/* validate the request */
$account = $request->user()->accounts()->findOrFail($request->input('account'));
$recipient = Account::findOrFail($request->input('recipient'));
$amount = $request->input('amount');
$lock = Cache::lock($account->id.':payment:send', 10, 'account:'$account->id);
if (! $lock->get()) {
return to_route('payments.create')
->with('status', [
'type' => 'error',
'message' => 'There was a problem processing your request.',
]);
}
dispatch(new ProcessPayment($account, $recipient, $amount, $lock->owner()));
return to_route('payments.create')
->with('status', [
'type' => 'success',
'message' => 'You have successfully sent a payment of '.number_format($request->input('amount'), 2).' to '.$recipient->name.'.',
]);
}
The updated code above will work fine; the lock will be automatically released after 10 seconds. But what if we want to release the lock once the job is finished instead of waiting 10 seconds for the lock to expire?
That’s why we passed the lock’s owner token as the fourth argument to the ProcessPayment
job. It will be used to release the lock once the job is finished.
We will look at how to do that in the ProcessPayment
job below:
<?php
namespace App\Jobs;
use App\Models\Account;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private Account $account,
private Account $recipient,
private int $amount,
private string $owner,
) {
}
public function handle(): void
{
$lock = Cache::restoreLock($this->account->id.':payment:send', $this->owner);
DB::transaction(function () use ($lock) {
/* process the request */
$lock->release();
});
}
}
Within our ProcessPayment
job, we utilize the Cache::restoreLock
method, which was first introduced in Laravel 5.8 following a pull request contribution by @janpantel.
This method accepts two arguments:
name
: This corresponds to the lock’s name and aligns with how we initially created the lock.
owner
: This argument specifies the owner token of the lock, which we passed as the fourth argument to the ProcessPayment
job.
Additionally, we encapsulate the payment request processing code within a database transaction. Finally, to ensure the proper release of the lock, we invoke the release
method once the payment processing is completed.
It’s worth noting that the use of a database transaction is optional, depending on your specific application requirements. However, I would strongly recommend using a database transaction, especially when handling financial transactions.