Support the ongoing development of Laravel.io →

Preventing Duplicate Form Submissions Using Atomic Locks

7 Mar, 2024 6 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 <strong>5.8</strong> 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.

Last updated 2 months ago.

driesvints, antoniputra liked this article

2
Like this article? Let the author know and give them a clap!

Other articles you might like

May 21st 2024

How to change the default SSR port for Inertia.js

Recently I had to run two Inertia.js applications on a single server and both of these applications...

Read article
May 12th 2024

Laravel Under The Hood - The Strategy Pattern

Hello 👋 Wikipedia: In computer programming, the strategy pattern (also known as the policy patter...

Read article
May 6th 2024

Encrypting Queued Jobs, Notifications, Mail, and Listeners in Laravel

Introduction There may be times when your Laravel application's queued jobs, notifications, mailable...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.