How to Prevent Race Conditions in Laravel with Atomic Locks

August 19, 2025
Written by
Prosper Ugbovo
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Prevent Race Conditions in Laravel with Atomic Locks

Consider an e-commerce business in which two consumers attempt to purchase the final available unit of a product simultaneously. During processing, the system may enable both customers to buy the item, resulting in an oversell. This might result in unfulfilled orders, inaccurate financial transactions, and frustrated customers.

Otherwise known as race conditions, this is when two or more processes attempt to access and modify shared data. They are particularly problematic in web applications. This is because most web requests are stateless and handled independently, meaning multiple instances of the same operation can run concurrently without awareness of one another.

To prevent such problems, Laravel provides atomic locks through its cache system. These locks ensure that critical code sections execute in a controlled manner, preventing simultaneous access by multiple processes.

In this article, we will examine how to prevent race conditions using Laravel’s atomic locking mechanisms.

Prerequisites

  • Prior knowledge of Laravel and PHP
  • Familiarity with Docker
  • PHP 8.4
  • Composer, Docker Compose, and curl installed globally
  • Your preferred text editor or IDE

How atomic locks work

Atomic locks ensure that only one process can execute a specific code block at a time. Laravel's cache system provides an easy-to-use locking mechanism that prevents the simultaneous execution of critical operations.

In simple terms, atomic locks in Laravel create a unique key in the cache that serves as a lock. If a process acquires the lock, other processes attempting to access the same resource must wait until the lock is released or expires.

Here's how the process works:

  1. A process attempts to acquire a lock before executing a critical operation
  2. If the lock is available, the process obtains it and runs the task
  3. If another process tries to acquire the same lock, it will either wait or fail, depending on the implementation
  4. Once the operation completes, the system releases the lock, allowing other processes to proceed
  5. If a process crashes before releasing the lock, Laravel ensures it expires after a set duration

Remember the e-commerce shop scenario in the introduction, in which two people attempt to purchase the last remaining product simultaneously? Let's simulate it.

Set up the project

To begin, create a new Laravel project using Composer. First, install the Laravel installer via Composer:

composer global require laravel/installer

After installing the Laravel installer, you can create a new Laravel application. The Laravel installer will prompt you to pick your preferred starter kit, choose "No starter kit". You will also be prompted to choose a database the application will use. When so, choose "MySQL".

laravel new race-condition

Once the application has been created, navigate to the project working directory.

cd race-condition

The next step is to create the necessary files to handle the app's business logic; a model, migration, and controller file. To do this, run the following command.

php artisan make:model Product -mc

In your IDE, open the newly created migration file in the database/migrations directory, which ends in _create_products_table.php, and replace the existing code with the following:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    /**
      * Run the migrations.
      */
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->integer('stock');
            $table->timestamps();
        });
    }

    /**
      * Reverse the migrations.
      */
    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Next, in the app/Models/Product.php file, add the class property below:

protected $guarded = ['id'];

Finally, in the controller file app/Http/Controllers/ProductController.php, replace its contents with the following:

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;

class ProductController extends Controller
{
    public function process($pId): ?JsonResponse
    {
        $product = Product::find($pId);
        sleep(5); // Simulating a delay to increase race condition chances

        if ($product->stock > 0) {
            --$product->stock;
            $product->save();
            Log::info("Product {$pId} purchased. Remaining stock: {$product->stock}");
            return response()->json([
                'message' => "Product purchased! Remaining stock: {$product->stock}"
            ]);
        }

        return response()->json([
            'message' => 'Out of stock!'
        ], 400);
    }
}

Here, we see a perfect example of a race condition. Why is this problematic? If multiple users execute this function simultaneously, they may both read the same stock value (for example, "stock = 1") before either one updates it. Both processes will decrease the stock object, leading to an incorrect stock value, resulting in overselling.

Before testing out the code, the database needs to be seeded and a route needs to be set up. Add the following routes to routes/web.php, along with the use statement.

use App\Http\Controllers\ProductController;

Route::get('/process/{id}', [ProductController::class, 'process']);

Then, update database/seeders/DatabaseSeeder.php to match the code below, which will seed a test product with limited stock:

<?php

namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
      * Seed the application's database.
      */
    public function run(): void
    {
        // User::factory(10)->create();
        // User::factory()->create([
        //     'name' => 'Test User',
        //     'email' => 'test@example.com',
        // ]);

        Product::create([
            'name' => 'Laptop',
            'stock' => 1
        ]);
    }
}

Now, create a bash script called test.sh in the project's top-level directory to handle the test, then paste the following into it.

#!/bin/bash

# URL of your endpoint  
URL="http://localhost/process/1"  

# Number of concurrent requests  
NUM_REQUESTS=2  

# Function to make the request  
make_request() {  
    curl -s -X GET "$URL"  
}  

# Run requests in parallel  
for (( i=1; i<=NUM_REQUESTS; i++ ))  
do  
    make_request &  
done  

# Wait for all background processes to complete  
wait

Furthermore, add the execute permission to the script:

chmod +x test.sh

Setup a multi-threaded test server

To make this work, a multi-threaded server, such as Nginx or Apache is needed. PHP's built-in server is single-threaded, meaning it cannot handle true concurrency (processing multiple requests at once). So, we'll build an environment with Docker Compose that provides Apache 2.

Start by creating a new docker/webserver directory and in that directory, a new file named Dockerfile. Then paste the code below into the file.

ARG PHP_VERSION=8.4

FROM php:${PHP_VERSION}-rc-apache-bookworm AS base

FROM base AS development

RUN a2enmod rewrite

RUN pecl install redis \
    && docker-php-ext-install pdo pdo_mysql \
    && docker-php-ext-enable redis

ENV APACHE_DOCUMENT_ROOT /var/www/html/public

RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf

RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf

Next, in your project's top-level directory, create a file named compose.yml. In the new file, paste the configuration below.

services:

    webserver:
        build:
            context: './docker/webserver'
            dockerfile: Dockerfile
        ports:
            - '${APP_PORT:-80}:80'
        user: "${UID:-1000}:${GID:-1000}"
        volumes:
            - '.:/var/www/html'
        depends_on:
            - mysql

    mysql:
        image: 'mysql/mysql-server:8.0'
        ports:
            - '${FORWARD_DB_PORT:-3306}:3306'
        environment:
            MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
            MYSQL_ROOT_HOST: '%'
            MYSQL_DATABASE: '${DB_DATABASE}'
            MYSQL_USER: '${DB_USERNAME}'
            MYSQL_PASSWORD: '${DB_PASSWORD}'
            MYSQL_ALLOW_EMPTY_PASSWORD: 1
        volumes:
            - 'mysql-data:/var/lib/mysql'
        healthcheck:
            test:
                - CMD
                - mysqladmin
                - ping
                - '-p${DB_PASSWORD}'
            retries: 3
            timeout: 5s

volumes:
    mysql-data:
        driver: local

Also, you will need to configure the host to receive connections from the "mysql" container. To do that, in the .env file update the DB_HOST variable:

DB_HOST=mysql

Now that all is set, it's time to start all containers in detached mode:

docker compose up -d
It is important to note that this Docker configuration is specifically designed for this tutorial and is unsuitable for production.

With the container running, all that remains is to migrate and seed the database and test the application in the container terminal. Run the following commands in sequence to do so:

docker compose exec webserver  php artisan migrate --seed

And finally:

./test.sh

You should see the following output written to the terminal.

{"message": "Product purchased! Remaining stock: 0"}{"message": "Product purchased! Remaining stock: 0"}

Implement a locking mechanism

The message displayed in the terminal shows that, although the product was out of stock, the order was processed, which should not have happened.

To resolve this issue, we need to implement a locking mechanism to ensure that only one process can modify the stock count at a time. To create this mechanism, we use the Cache::lock() method, which accepts two required 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 the lock should remain valid. Locks should have an expiration time to prevent deadlocks, ideally 10 seconds

Update the process() method in the controller app/Http/Controllers/ProductController.php file to match the code below.

public function process($pId): ?JsonResponse
{
    $lockKey = "purchase-lock-{$pId}";
    $lock = Cache::lock($lockKey, 10); // 10-second lock
    if ($lock->get()) {
        try {
            $product = Product::find($pId);
            if ($product->stock > 0) {
                sleep(5); // Simulating delay
                --$product->stock;
                $product->save();
                Log::info("Product {$pId} purchased (with lock). Remaining stock: {$product->stock}");
                return response()->json(['message' => "Product purchased! Remaining stock: {$product->stock}"]);
            }
            return response()->json(['message' => 'Out of stock!'], 400);
        } finally {
            $lock->release();
        }
    } else {
        return response()->json(['message' => "Product {$pId} is being purchased. Try again."], 400);
    }
}

Then, add the use statement below to the top of the file.

use Illuminate\Support\Facades\Cache;

The code above creates a lock named purchase-lock-{$pId} that lasts for 10 seconds. The request will be processed if the lock is obtained, and the rest of the operation will be attended to. If the lock is not obtained, an error message will be returned.

Test that the locking mechanism works

To test the new modifications, re-seed the database by running the command below in the docker container.

docker compose exec app php artisan migrate:fresh --seed

Then run the bash script outside the docker container:

./test.sh

You should see a message showing that just one order was aborted, while the second request was processed, which you can see below.

{"message":"Product 1 is being purchased. Try again."}{"message":"Product purchased! Remaining stock: 0"}

You might be wondering why the aborted message appeared first. This is because while the first order was being processed, the cache lock wasn't released yet, causing the second order to fail nearly immediately.

That's how to prevent race conditions in Laravel with atomic locks

Race conditions can create serious problems in Laravel applications, resulting in duplicate transactions, data inconsistencies, and other errors. Laravel’s atomic locks easily prevent these problems by ensuring only one process can execute a given task at a time.

Implementing atomic locks can protect your data and improve the reliability of your Laravel applications. Happy building!

Prosper is a freelance Laravel web developer and technical writer who enjoys working on innovative projects that use open-source software. When he's not coding, he searches for the ideal startup opportunities to pursue. You can find him on Twitter and LinkedIn.

The atom icon in the post's social image was created by Freepik on Flaticon.