Laravel E-Commerce API with Optimistic Locking

Build a race-condition-proof ordering API using Eloquent and optimistic locking.

Introduction

30 Second Summary

Ever added the last item to your online cart, only to discover someone else bought it at the exact same moment? Preventing two buyers from purchasing that single remaining unit is a real engineering challenge that every e-commerce platform has to solve.

In this project, you will build an e-commerce ordering API with Laravel 8. You will intentionally trigger a race condition that oversells inventory, then fix it with optimistic locking so the second buyer gets a clean rejection instead of a corrupted database.

What You'll Build

You will fire two simultaneous checkout requests for the last unit in stock and watch your API accept one order while rejecting the other with a 409 response, leaving inventory at exactly zero.

By the end of this project, you'll have:

  • A browsable product catalog where a single curl request returns five seeded products with their names, prices, and live stock counts from your MySQL database.
  • A shopping cart you can fill by posting items and inspect to see product names, quantities, and running totals.
  • A race-condition-proof checkout that handles two simultaneous buyers fighting over the last unit. One order goes through, the other receives a conflict response, and your inventory stays at exactly zero instead of dipping to negative one.
  • Secret Mission: Add an order cancellation endpoint that restores inventory using the same optimistic locking pattern.

Are there any prerequisites?

This project assumes you are comfortable with PHP basics. You will need PHP 7.4, Composer, MySQL, and a code editor like VS Code or Cursor installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Laravel 8 Project

Every API needs a foundation. Before you can build endpoints for browsing products or placing orders, you need a running Laravel application connected to a database.

In this step, you'll scaffold a fresh Laravel 8 project with Composer, wire it up to MySQL, and start the development server.

In this step, get ready to:
  • Create a new Laravel 8 project with Composer.
  • Connect the project to a MySQL database.
  • Start the development server and see the welcome page.
Create the Laravel 8 project and configure the database

You'll use Composer to download Laravel 8 and generate the project scaffolding. Then you'll create a MySQL database and point the project at it.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Laravel 8 project by running this command:
composer create-project laravel/laravel ecommerce-api "8.*"

What does this command do?

This tells Composer to download Laravel version 8 and set up a new project in a folder called ecommerce-api. The "8.*" part pins the version to Laravel 8.

This download may take a minute or two. You'll see Composer fetching packages and listing them in your terminal.

  • Move into the new project folder and verify the files were created by running:
cd ecommerce-api
ls

You should see Laravel's directory structure including folders like app, database, routes, and files like .env and artisan.

Seeing an error from Composer?

  • Make sure you ran cd ~/Desktop first so the project folder is created on your Desktop.
  • Check that Composer is installed by running composer --version in your terminal.
  • If you see a PHP version error, confirm you have PHP 7.3 or higher by running php -v.

Still stuck? Help me troubleshoot this Composer error.

Your Laravel project needs a MySQL database to store products, carts, and orders. Let's create one.

  • Create the MySQL database by running this command (it will prompt for your MySQL password):
mysql -u root -p -e "CREATE DATABASE ecommerce_api;"

What does this command do?

  • -u root specifies the MySQL username. If your MySQL user is not root, replace it with your username.
  • -p prompts you to enter your MySQL password securely.
  • -e runs the SQL statement inline and exits. This creates a database called ecommerce_api.

If the command completes with no error output, the database was created successfully.

Access denied or command not found?

  • If you see an "Access denied" error, double check your MySQL username and password. You may need to replace root with a different username.
  • If you see "command not found", make sure MySQL is installed and its binary is on your system PATH.

Need more help? Help me connect to MySQL from the terminal.

Now you need to tell Laravel where to find that database. The connection details live in the .env file at the root of your project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the ecommerce-api folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click .env to open the file.
  • Find these three lines in the database section of .env:
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
  • Replace laravel with ecommerce_api so the database name matches the one you just created.
  • Update DB_USERNAME to your MySQL username (leave it as root if that is what you used earlier).
  • Update DB_PASSWORD to your MySQL password (the same one you entered when creating the database).

After your edits, those three lines should look like this:

DB_DATABASE=ecommerce_api
DB_USERNAME=root
DB_PASSWORD=your-mysql-password

Why ecommerce_api?

The DB_DATABASE value must exactly match the database name you created in MySQL. A mismatch here means Laravel cannot find the database, and migrations will fail in the next step.

  • Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Run migrations to confirm the database connection

Laravel uses migrations to create database tables from PHP code. Running the default migrations now proves that your database connection is working.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Run the default migrations by running this command:
php artisan migrate

What are migrations?

Migrations are version-controlled PHP files that define your database tables. Each migration creates or modifies a table. Laravel ships with a few default migrations for tables like users and failed_jobs.

Running php artisan migrate executes all pending migrations against your database. You'll write your own migrations in the next step.

You should see output listing each migration as it runs, with lines like Migrating: followed by Migrated: for each table.

Seeing a connection refused error?

  • Make sure your MySQL server is running. On macOS, check System Preferences > MySQL. On Windows, open Services and look for MySQL.
  • Double check that DB_DATABASE, DB_USERNAME, and DB_PASSWORD in your .env file match the credentials you used when creating the database.
  • Confirm DB_HOST is set to 127.0.0.1 and DB_PORT is 3306 (the MySQL defaults).

Still stuck? Help me debug this Laravel database connection error.

Start the development server

With the database connected, you can fire up Laravel's built-in development server to make sure everything loads correctly.

  • Start the development server by running this command:
php artisan serve

What does this command do?

This starts a local web server at http://localhost:8000. The command takes over your terminal while the server is running. You can stop it anytime with Ctrl+C.

Before you open your browser, what do you think you'll see at http://localhost:8000?

  • Open your browser and visit http://localhost:8000.

You should see the Laravel welcome page with the Laravel logo and links to documentation. This confirms your application is running and ready for development.

Page not loading?

  • Check your terminal for error messages. If the server failed to start, it will show the reason there.
  • Make sure no other application is using port 8000. If so, stop it and try again.
  • Verify the URL is exactly http://localhost:8000 (with http, not https).

Still having issues? Help me get the Laravel development server running.

Your Laravel 8 project is running and connected to MySQL. Next up, you'll build the product catalog API and see real product data flowing through your first endpoint.

Build the Product Catalog API

Your Laravel project is running, but it has no data and no endpoints. A real e-commerce store needs products that customers can browse.

In this step, you will create your first two Eloquent models, seed the database with sample products, and expose them through a JSON API endpoint. By the end, you will have a working GET /api/products route returning real data.

In this step, get ready to:
  • Create Product and Inventory models with database migrations.
  • Seed the database with 5 sample products and their stock levels.
  • Build a ProductController that returns product data as JSON.
Create the Product and Inventory models

Every product in the store needs a name, a description, and a price. Each product also tracks how many units are in stock. You will represent these as two separate models linked by a relationship.

  • Open a new terminal tab or window so you can run commands while the development server keeps running.
  • Navigate to your project folder by running:
cd ~/Desktop/ecommerce-api
  • Generate the Product model and its migration by running:
php artisan make:model Product -m
  • Generate the Inventory model and its migration by running:
php artisan make:model Inventory -m

You should see Model created successfully and a Created Migration line for each command.

What did these commands create?

Each command created two files: a model class in app/Models/ and a migration file in database/migrations/. The model is the PHP class you use to interact with the database table. The migration defines the table's columns and structure.

  • Open the newly created products migration file in database/migrations/. It ends with _create_products_table.php.
  • Replace the entire file contents with the following:
<?php

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

class CreateProductsTable extends Migration
{
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            $table->decimal('price', 10, 2);
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('products');
    }
}

What does this migration do?

  • id() creates an auto-incrementing primary key.
  • string('name') stores the product name as a varchar column.
  • text('description')->nullable() allows a longer description that can be left blank.
  • decimal('price', 10, 2) stores the price with 2 decimal places, avoiding floating-point rounding issues.
  • timestamps() adds created_at and updated_at columns automatically.
  • Open the inventories migration file in database/migrations/. It ends with _create_inventories_table.php.
  • Replace the entire file contents with the following:
<?php

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

class CreateInventoriesTable extends Migration
{
    public function up()
    {
        Schema::create('inventories', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->onDelete('cascade');
            $table->integer('quantity')->default(0);
            $table->unsignedInteger('version')->default(1);
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('inventories');
    }
}

What does this migration do?

  • foreignId('product_id')->constrained()->onDelete('cascade') creates a foreign key linking each inventory record to a product. If the product is deleted, the inventory record is removed too.
  • integer('quantity') tracks how many units are in stock, defaulting to 0.
  • unsignedInteger('version') is a column you will use in a later step. It defaults to 1 for now.
  • Run both migrations by running:
php artisan migrate

You should see output listing each migration file as it runs, confirming the products and inventories tables were created.

Seeing a connection refused error?

Make sure your MySQL server is running. Check that the DB_DATABASE, DB_USERNAME, and DB_PASSWORD values in your .env file match your local MySQL credentials.

Help me fix my database connection error in Laravel

Now define the relationships and fillable fields on each model.

  • Open app/Models/Product.php and replace the entire file contents with:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    // Allow these fields to be mass-assigned
    protected $fillable = ['name', 'description', 'price'];

    // Each product has one inventory record
    public function inventory()
    {
        return $this->hasOne(Inventory::class);
    }

    // Each product can appear in many order items
    public function orderItems()
    {
        return $this->hasMany(OrderItem::class);
    }
}

What does this model do?

The $fillable array tells Laravel which columns are safe to set via mass assignment. Without it, Product::create([...]) would throw an error.

The inventory() method defines a hasOne relationship. It means each product has exactly one inventory record. The orderItems() relationship will be used in a later step when you build the checkout flow.

  • Open app/Models/Inventory.php and replace the entire file contents with:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Inventory extends Model
{
    use HasFactory;

    // Allow these fields to be mass-assigned
    protected $fillable = ['product_id', 'quantity', 'version'];

    // Each inventory record belongs to one product
    public function product()
    {
        return $this->belongsTo(Product::class);
    }
}

What does belongsTo mean?

The belongsTo relationship is the inverse of hasOne. The Product says "I have one Inventory," and the Inventory says "I belong to one Product." Laravel uses the product_id foreign key to connect them.

  • Save both model files.
Seed the database with sample products

Your tables exist but they are empty. A seeder is a Laravel class that populates your database with sample data so you have something to query.

  • Generate a new seeder class by running:
php artisan make:seeder ProductSeeder
  • Open database/seeders/ProductSeeder.php and replace the entire file contents with:
<?php

namespace Database\Seeders;

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

class ProductSeeder extends Seeder
{
    public function run()
    {
        // Define 5 sample products with their stock levels
        $products = [
            ['name' => 'Wireless Mouse', 'description' => 'Ergonomic wireless mouse with USB receiver', 'price' => 29.99, 'stock' => 10],
            ['name' => 'Mechanical Keyboard', 'description' => 'RGB mechanical keyboard with blue switches', 'price' => 79.99, 'stock' => 5],
            ['name' => 'USB-C Hub', 'description' => '7-in-1 USB-C hub with HDMI output', 'price' => 49.99, 'stock' => 8],
            ['name' => 'Webcam HD', 'description' => '1080p webcam with built-in microphone', 'price' => 59.99, 'stock' => 3],
            ['name' => 'Monitor Stand', 'description' => 'Adjustable aluminum monitor stand', 'price' => 39.99, 'stock' => 15],
        ];

        foreach ($products as $productData) {
            // Separate stock from product data
            $stock = $productData['stock'];
            unset($productData['stock']);

            // Create the product and its inventory record
            $product = Product::create($productData);
            Inventory::create([
                'product_id' => $product->id,
                'quantity' => $stock,
            ]);
        }
    }
}

What does this seeder do?

The seeder defines an array of 5 products, each with a name, description, price, and stock count. It loops through the array and creates a Product record and a matching Inventory record for each one. The unset($productData['stock']) call removes the stock key before passing the data to Product::create(), because stock is not a column on the products table.

  • Open database/seeders/DatabaseSeeder.php and replace the entire file contents with:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->call([
            ProductSeeder::class,
        ]);
    }
}

Why edit DatabaseSeeder?

Laravel's db:seed command runs DatabaseSeeder by default. By calling ProductSeeder from inside it, you ensure your product data is seeded whenever you run the command.

  • Save both seeder files.
  • Run the seeder by running:
php artisan db:seed

You should see the seeder class name followed by DONE, confirming 5 products were inserted into your database.

Seeing a class not found error?

Make sure the use App\Models\Product; and use App\Models\Inventory; import statements are at the top of your ProductSeeder.php file. Also check that you saved both model files from the previous substep.

Help me fix my seeder class not found error

Build the product API endpoint

Your database has products, but there is no way to access them over HTTP yet. You need a controller to read the data and a route to expose it as a JSON endpoint.

  • Generate an API controller by running:
php artisan make:controller ProductController --api
  • Open app/Http/Controllers/ProductController.php and replace the entire file contents with the following. This defines the index method that returns all products:
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\JsonResponse;

class ProductController extends Controller
{
    // Return all products with their stock levels
    public function index(): JsonResponse
    {
        $products = Product::with('inventory')->get()->map(function ($product) {
            return [
                'id' => $product->id,
                'name' => $product->name,
                'description' => $product->description,
                'price' => $product->price,
                'stock' => $product->inventory ? $product->inventory->quantity : 0,
            ];
        });

        return response()->json($products);
    }
}

What does the index method do?

  • Product::with('inventory') eager-loads the related inventory record for every product in a single query. Without it, Laravel would run a separate query for each product.
  • get()->map(...) fetches all products and transforms each one into a clean array with just the fields you want to expose. The stock field pulls the quantity from the related inventory.
  • response()->json(...) converts the collection into a JSON HTTP response.
  • Open routes/api.php and replace the entire file contents with:
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CheckoutController;

// Register product endpoints: GET /api/products and GET /api/products/{id}
Route::apiResource('products', ProductController::class)->only(['index', 'show']);

What does apiResource do?

Route::apiResource automatically registers RESTful routes for a controller. The ->only(['index', 'show']) call limits it to just the two read-only endpoints: GET /api/products (list all) and GET /api/products/{product} (show one).

The CartController and CheckoutController imports are set up ahead of time for controllers you will create in the next steps. They will not cause errors because PHP only resolves these imports when they are actually used in code.

  • Save both files.

Before you run the next command, what do you expect to see in the response? Think about what data the seeder inserted.

  • Test the products endpoint by running:
curl http://localhost:8000/api/products

You should see a JSON array of 5 products, each with an id, name, description, price, and stock field. The first product should be "Wireless Mouse" with a stock of 10.

Seeing a 500 error or empty response?

Make sure the development server is still running in your other terminal tab. If it stopped, restart it with php artisan serve.

Check that the routes/api.php file has the use App\Http\Controllers\ProductController; import and the Route::apiResource line. A missing import or a typo in the controller class name will cause a 500 error.

Help me fix my 500 error on the products endpoint

Now add the show method so you can fetch a single product by its ID.

  • In app/Http/Controllers/ProductController.php, find the closing } of the class.
  • Add the following show method just above that closing brace:
    // Return a single product by ID
    public function show(Product $product): JsonResponse
    {
        $product->load('inventory');

        return response()->json([
            'id' => $product->id,
            'name' => $product->name,
            'description' => $product->description,
            'price' => $product->price,
            'stock' => $product->inventory ? $product->inventory->quantity : 0,
        ]);
    }

What does the show method do?

The Product $product parameter uses Laravel's route model binding. When a request hits GET /api/products/1, Laravel automatically finds the Product with ID 1 and passes it into the method. If the product does not exist, Laravel returns a 404 response automatically.

The $product->load('inventory') call lazy-loads the inventory relationship for just this one product.

  • Save ProductController.php.
  • Test the show endpoint by running:
curl http://localhost:8000/api/products/1

You should see a single JSON object for "Wireless Mouse" with a stock of 10 and a price of 29.99.

✔️ Awesome, I've got everything!

Great. Double check you have saved ProductController.php and routes/api.php.

ⓧ I'd like to double check the full code

Your ProductController.php was built in two chunks. Scroll up and confirm both are present in this order:

  • The file header with the namespace, use imports, and the index() method.
  • The show() method, placed just before the closing } of the class.

Your routes/api.php should look like this:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CheckoutController;

Route::apiResource('products', ProductController::class)->only(['index', 'show']);

Your product catalog is live. You can browse all 5 products and fetch any single product by ID. Next up, you will build a shopping cart so customers can collect items before checkout.

Build the Shopping Cart

Your product catalog is live. Customers can browse the store and see what's available with stock levels. But browsing is only half the story.

Customers need a way to collect items before they commit to buying. In this step, you'll build a shopping cart using two new Eloquent models: Cart and CartItem. The cart introduces multi-model relationships, where a Cart hasMany CartItems, and each CartItem belongsTo both a Cart and a Product.

In this step, get ready to:
  • Generate Cart and CartItem models with migrations and relationships.
  • Build endpoints to add products to a cart and view cart contents.
  • Test the cart flow by adding a product and verifying the JSON response.
Generate the Cart and CartItem models

Just like you created Product and Inventory in the previous step, you need two new models: one for the cart itself, and one for each item inside the cart. Laravel's artisan generator creates both the model file and its migration in one command.

  • Generate the Cart and CartItem models with their migrations by running these commands:
php artisan make:model Cart -m
php artisan make:model CartItem -m

What did these commands create?

Each command created two files: a model in app/Models/ and a migration in database/migrations/. The -m flag is a shortcut for --migration.

  • Open the newly created carts migration file in database/migrations/. It has a name ending in _create_carts_table.php.
  • Replace its entire contents with this code:
<?php

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

class CreateCartsTable extends Migration
{
    public function up()
    {
        Schema::create('carts', function (Blueprint $table) {
            $table->id();
            // Unique identifier for each shopping cart session
            $table->string('cart_token')->unique();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('carts');
    }
}

What is the cart_token column?

Instead of tying carts to user accounts (which would require authentication), you'll identify each cart with a unique string token. This keeps the API stateless and easy to test with curl. The caller picks any token they want and passes it with each request.

  • Save the carts migration file.
  • Now open the cart items migration file. It has a name ending in _create_cart_items_table.php.
  • Replace its entire contents with this code:
<?php

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

class CreateCartItemsTable extends Migration
{
    public function up()
    {
        Schema::create('cart_items', function (Blueprint $table) {
            $table->id();
            // Foreign keys linking to carts and products tables
            $table->foreignId('cart_id')->constrained()->onDelete('cascade');
            $table->foreignId('product_id')->constrained()->onDelete('cascade');
            $table->unsignedInteger('quantity');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('cart_items');
    }
}

What do the foreign keys do?

The foreignId columns create foreign key constraints linking each cart item to a specific cart and product. The onDelete('cascade') clause means deleting a cart automatically removes all its items from the database.

  • Save the cart items migration file.
  • Run the migrations to create both tables by running:
php artisan migrate

You should see output confirming both the carts and cart_items tables were created.

Migration failed?

If you see a "table already exists" error, a previous migration may have partially run. Run php artisan migrate:rollback to undo the last batch, fix the migration file, then run php artisan migrate again.

If you see a foreign key error, make sure your carts migration file was created before the cart_items file (check the timestamps in the filenames). The cart_items table depends on the carts table existing first.

Help me fix my migration error

Now that the tables exist, you need to tell Eloquent how the Cart and CartItem models relate to each other and to the existing Product model.

  • Open app/Models/Cart.php and replace its entire contents with this code:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Cart extends Model
{
    use HasFactory;

    // Allow mass assignment for cart_token
    protected $fillable = ['cart_token'];

    // A cart contains many cart items
    public function items()
    {
        return $this->hasMany(CartItem::class);
    }
}
  • Save Cart.php.
  • Open app/Models/CartItem.php and replace its entire contents with this code:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class CartItem extends Model
{
    use HasFactory;

    // Allow mass assignment for these fields
    protected $fillable = ['cart_id', 'product_id', 'quantity'];

    // Each cart item belongs to a single cart
    public function cart()
    {
        return $this->belongsTo(Cart::class);
    }

    // Each cart item references a product
    public function product()
    {
        return $this->belongsTo(Product::class);
    }
}

How do these relationships work?

Cart uses hasMany to say "one cart contains many cart items." CartItem uses belongsTo twice: once to link back to its parent Cart, and once to link to the Product it represents.

These relationships let you write expressive queries like $cart->items to get all items in a cart, or $cartItem->product->name to get the product name for a specific item.

  • Save CartItem.php.
Create the add-to-cart endpoint

With the models and database tables ready, you need a controller to handle cart operations. The first endpoint lets a customer add a product to their cart by sending a POST request with a cart token, product ID, and quantity.

  • Create a new file called CartController.php in the app/Http/Controllers folder.
  • Paste the following code into the new file:
<?php

namespace App\Http\Controllers;

use App\Models\Cart;
use App\Models\CartItem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CartController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        // Validate the incoming request data
        $validated = $request->validate([
            'cart_token' => 'required|string',
            'product_id' => 'required|exists:products,id',
            'quantity' => 'required|integer|min:1',
        ]);

        // Find existing cart or create a new one
        $cart = Cart::firstOrCreate(['cart_token' => $validated['cart_token']]);
    }
}

What does this code do?

The validate() method checks that the request includes a cart_token string, a product_id that exists in the products table, and a positive quantity. If validation fails, Laravel automatically returns a 422 error.

The firstOrCreate() method is the key to handling both new and returning shoppers. It looks up a cart by the token. If one exists, it returns it. If not, it creates a new cart with that token.

The store method validates and finds the cart, but doesn't yet handle the cart item itself. You need to add the logic that either updates an existing item's quantity or creates a new cart item.

  • In CartController.php, find this line:
        $cart = Cart::firstOrCreate(['cart_token' => $validated['cart_token']]);
  • Copy the following code directly below that line, before the method's closing brace:
        // Check if this product is already in the cart
        $cartItem = CartItem::where('cart_id', $cart->id)
            ->where('product_id', $validated['product_id'])
            ->first();

        // Update quantity if it exists, otherwise create new item
        if ($cartItem) {
            $cartItem->quantity += $validated['quantity'];
            $cartItem->save();
        } else {
            CartItem::create([
                'cart_id' => $cart->id,
                'product_id' => $validated['product_id'],
                'quantity' => $validated['quantity'],
            ]);
        }

        return response()->json(['message' => 'Item added to cart'], 201);

What does the update-or-create logic do?

The code first checks if the product is already in this cart. If it is, it increases the existing quantity instead of adding a duplicate row. If not, it creates a brand-new CartItem.

This means a customer who adds "Wireless Mouse" twice with quantity 1 each time ends up with a single cart item showing quantity 2.

  • Save CartController.php.
  • Open routes/api.php and find the Route::apiResource line.
  • Add this line below it to register the add-to-cart route:
Route::post('/cart', [CartController::class, 'store']);
  • Save routes/api.php.
  • Test the add-to-cart endpoint by running this curl command:
curl -X POST http://localhost:8000/api/cart \
  -H "Content-Type: application/json" \
  -d '{"cart_token": "my-cart-123", "product_id": 1, "quantity": 2}'

You should see this JSON response confirming the item was added:

{"message":"Item added to cart"}

Seeing a 404 or 500 error?

If you get a 404, make sure you saved routes/api.php with the new route line. Double-check that the route uses Route::post and the path is /cart.

If you get a 500 error, check that CartController.php has the correct namespace (App\Http\Controllers) and that the use statements at the top import Cart, CartItem, JsonResponse, and Request.

Help me debug my add-to-cart endpoint

Add the view-cart endpoint

Customers can add items, but they also need to see what's in their cart. The view-cart endpoint takes a cart token and returns every item with its product name, unit price, and a line total.

  • In CartController.php, find the closing brace of the store method. Add this new method below it, still inside the class:
    public function show($cart_token): JsonResponse
    {
        // Look up the cart by its token
        $cart = Cart::where('cart_token', $cart_token)->first();

        if (!$cart) {
            return response()->json(['message' => 'Cart not found'], 404);
        }

        // Load cart items with product details
        $items = $cart->items()->with('product')->get()->map(function ($item) {
            return [
                'product_id' => $item->product_id,
                'product_name' => $item->product->name,
                'quantity' => $item->quantity,
                'unit_price' => $item->product->price,
                'line_total' => $item->quantity * $item->product->price,
            ];
        });

        // Calculate the cart total
        $total = $items->sum('line_total');

        return response()->json([
            'cart_token' => $cart_token,
            'items' => $items,
            'total' => $total,
        ]);
    }

What does the show method do?

It looks up the cart by its token. If no cart matches, it returns a 404. The with('product') call is eager loading. It fetches all related product records in a single query instead of running a separate query for each cart item.

The map() transforms each CartItem into a clean JSON shape with the product name, unit price, and a calculated line total. The sum('line_total') adds them up into a cart total.

  • Save CartController.php.
  • Open routes/api.php and find the Route::post('/cart', ...) line you just added.
  • Add this line below it to register the view-cart route:
Route::get('/cart/{cart_token}', [CartController::class, 'show']);
  • Save routes/api.php.

Before you run the next command, think about what you expect the response to look like. You added 2 units of product 1 (Wireless Mouse, priced at 29.99) to the cart earlier. What should the line total and cart total be?

  • View your cart contents by running:
curl http://localhost:8000/api/cart/my-cart-123

You should see a JSON response with the Wireless Mouse, a quantity of 2, a unit price of 29.99, and a line total of 59.98.

✔️ Awesome, I've got everything!

Great. Double check you've saved both CartController.php and routes/api.php.

ⓧ I'd like to double check the full code

Your CartController.php should have two methods: store and show. Scroll up and confirm both methods match the code you pasted above. Make sure the store method includes both the validation chunk and the update-or-create chunk combined.

Your routes/api.php should look like this:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CheckoutController;

Route::apiResource('products', ProductController::class)->only(['index', 'show']);

Route::post('/cart', [CartController::class, 'store']);
Route::get('/cart/{cart_token}', [CartController::class, 'show']);

Cart not found or empty response?

Make sure the cart token in your GET request matches the one you used in the POST request. Both must be my-cart-123 (case-sensitive).

If you see an empty items array, the POST request may not have saved correctly. Re-run the POST command and check that you get {"message":"Item added to cart"} before trying the GET again.

Help me debug my view-cart endpoint

Your shopping cart is working. Customers can add products and see their cart contents with calculated totals. Next up, you'll build the checkout endpoint that converts a cart into an order and deducts inventory.

Checkout and the Overselling Bug

Your product catalog serves data and customers can load up a shopping cart. The final piece of any e-commerce flow is checkout: converting that cart into a real order and deducting inventory.

The checkout logic sounds straightforward. Read the cart, create an order, subtract stock. But there is a hidden trap lurking in this approach. What happens when two customers try to buy the last item at the exact same moment?

In this step, get ready to:
  • Create Order and OrderItem models with migrations and relationships.
  • Build a checkout endpoint that creates orders and deducts inventory.
  • Trigger a race condition that oversells a product.
Generate Order and OrderItem models

An order captures what the customer bought, how much they paid, and how many of each item. You need two new models: Order for the order itself, and OrderItem for each line item in that order.

  • Generate both models with their migrations by running these commands:
php artisan make:model Order -m
php artisan make:model OrderItem -m

What do these commands do?

The -m flag tells Artisan to create both the model file and a matching database migration in one step. You get four new files: two models in app/Models/ and two migrations in database/migrations/.

Now define the columns each table needs. The orders table tracks the order status and total price.

  • Open the newly created orders migration file in database/migrations/. Its filename ends with _create_orders_table.php.
  • Find the Schema::create block inside the up() method and replace it with this:
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            // Track order status (completed, cancelled, etc.)
            $table->string('status')->default('completed');
            // Store the total price of the order
            $table->decimal('total', 10, 2)->default(0);
            $table->timestamps();
        });

What does this schema do?

The status column defaults to completed so every new order is immediately marked as fulfilled. The total column uses decimal(10, 2) for precise currency values.

Next, the order_items table links each line item back to its order and product.

  • Open the order items migration file. Its filename ends with _create_order_items_table.php.
  • Replace the Schema::create block in the up() method with this:
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            // Foreign keys linking to orders and products
            $table->foreignId('order_id')->constrained()->onDelete('cascade');
            $table->foreignId('product_id')->constrained()->onDelete('cascade');
            $table->unsignedInteger('quantity');
            // Snapshot the price at time of purchase
            $table->decimal('unit_price', 10, 2);
            $table->timestamps();
        });

Why snapshot the unit price?

Product prices can change over time. Storing unit_price on the order item captures what the customer actually paid, even if the product's price changes later.

  • Save both migration files.
  • Run the migrations by running this command:
php artisan migrate

You should see output confirming the orders and order_items tables were created.

Migration failed?

Check that your foreignId columns reference tables that already exist. The order_items migration depends on both orders and products. Make sure the orders migration timestamp comes before the order_items timestamp in the filename.

Help me debug a Laravel migration error

With the tables in place, define the Eloquent relationships so Laravel knows how these models connect.

  • Open app/Models/Order.php.
  • Add the $fillable property and the items() relationship inside the class body, below the use HasFactory; line:
    protected $fillable = ['status', 'total'];

    public function items()
    {
        // An order has many line items
        return $this->hasMany(OrderItem::class);
    }

What does this relationship do?

The hasMany relationship means one Order can have multiple OrderItem records. You can access them later with $order->items.

  • Open app/Models/OrderItem.php.
  • Add the $fillable property and both relationships inside the class body, below use HasFactory;:
    protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];

    public function order()
    {
        // Each item belongs to one order
        return $this->belongsTo(Order::class);
    }

    public function product()
    {
        // Each item references one product
        return $this->belongsTo(Product::class);
    }

What are these relationships?

Each OrderItem uses belongsTo to point back to its parent Order and to the Product it represents. This lets you traverse from an order item to its product with $orderItem->product.

  • Save both model files.
  • Confirm the models connect to the database by running this command:
php artisan tinker --execute="echo App\Models\Order::count()"

You should see 0 printed. No orders exist yet, but the model loaded without errors.

Seeing a class not found error?

Make sure the namespace at the top of each model file is App\Models. Also check that the class names match: Order in Order.php and OrderItem in OrderItem.php.

Help me fix a class not found error in Laravel

✔️ Awesome, I've got everything!

Great. Make sure you have saved both Order.php and OrderItem.php.

ⓧ I'd like to double check the full code

Here is the complete Order.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use HasFactory;

    protected $fillable = ['status', 'total'];

    public function items()
    {
        return $this->hasMany(OrderItem::class);
    }
}

And the complete OrderItem.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model
{
    use HasFactory;

    protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];

    public function order()
    {
        return $this->belongsTo(Order::class);
    }

    public function product()
    {
        return $this->belongsTo(Product::class);
    }
}
Build the checkout endpoint

The checkout endpoint reads a cart, creates an order with line items, deducts inventory for each product, and clears the cart. You will write the straightforward version first.

  • Generate the controller by running this command:
php artisan make:controller CheckoutController
  • Open app/Http/Controllers/CheckoutController.php.
  • Replace the entire file content with the imports and cart validation logic:
<?php

namespace App\Http\Controllers;

use App\Models\Cart;
use App\Models\Inventory;
use App\Models\Order;
use App\Models\OrderItem;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;

class CheckoutController extends Controller
{
    public function store($cart_token): JsonResponse
    {
        // Find the cart by its token
        $cart = Cart::where('cart_token', $cart_token)->first();

        if (!$cart) {
            return response()->json(['message' => 'Cart not found'], 404);
        }

        // Load all cart items with product details
        $cartItems = $cart->items()->with('product')->get();

        if ($cartItems->isEmpty()) {
            return response()->json(['message' => 'Cart is empty'], 400);
        }

What does this code do?

The method looks up the cart by its token and returns a 404 if it does not exist. It then loads all cart items eagerly with their product data using with('product'). If the cart is empty, it returns a 400 error.

  • Continue by pasting the order creation and inventory deduction logic directly below the code you just added:
        $order = Order::create(['status' => 'completed', 'total' => 0]);
        $total = 0;
        foreach ($cartItems as $cartItem) {
            $inventory = Inventory::where('product_id', $cartItem->product_id)->first();
            if (!$inventory || $inventory->quantity < $cartItem->quantity) {
                return response()->json(['message' => "Insufficient stock for {$cartItem->product->name}"], 400);
            }
            // Deduct stock (naive approach - no concurrency protection)
            $inventory->quantity -= $cartItem->quantity;
            $inventory->save();
            $lineTotal = $cartItem->quantity * $cartItem->product->price;
            OrderItem::create([
                'order_id' => $order->id,
                'product_id' => $cartItem->product_id,
                'quantity' => $cartItem->quantity,
                'unit_price' => $cartItem->product->price,
            ]);
            $total += $lineTotal;
        }
        $order->update(['total' => $total]);
        $cart->items()->delete();
        $cart->delete();

        return response()->json([
            'message' => 'Order placed successfully',
            'order_id' => $order->id,
            'total' => $order->total,
        ], 201);
    }
}

What does this code do?

This is the core checkout logic. For each cart item, it reads the inventory record, checks there is enough stock, and deducts the quantity directly with $inventory->quantity -= $cartItem->quantity. It then creates an OrderItem for each product.

After processing all items, it updates the order total, deletes the cart items, and removes the cart itself.

  • Save CheckoutController.php.

Now register the checkout route so the API knows where to send checkout requests.

  • Open routes/api.php.
  • Find the existing cart routes at the bottom of the file:
Route::post('/cart', [CartController::class, 'store']);
Route::get('/cart/{cart_token}', [CartController::class, 'show']);
  • Add this line directly below them:
Route::post('/checkout/{cart_token}', [CheckoutController::class, 'store']);
  • Save routes/api.php.

Time to test the happy path. First, add a product to a cart, then check out.

  • Add a product to a new cart by running this command:
curl -s -X POST http://localhost:8000/api/cart -H "Content-Type: application/json" -d '{"cart_token":"test-cart","product_id":1,"quantity":1}'

You should see a JSON response confirming the item was added.

  • Check out that cart by running this command:
curl -s -X POST http://localhost:8000/api/checkout/test-cart

You should see a response like:

{"message":"Order placed successfully","order_id":1,"total":"29.99"}
  • Confirm the stock was deducted by running this command:
curl -s http://localhost:8000/api/products/1

The stock value for Wireless Mouse should now be 9 instead of 10.

Checkout returning an error?

If you see a 500 error, check that all the use statements at the top of CheckoutController.php are present. A missing import for Order or OrderItem causes a class-not-found error.

If you see Cart not found, make sure you ran the add-to-cart curl command first and used the same cart_token value in both requests.

Help me debug my checkout endpoint

✔️ Awesome, I've got everything!

Great. Make sure CheckoutController.php and routes/api.php are both saved.

ⓧ I'd like to double check the full code

Your CheckoutController.php has two sections. Scroll up and confirm you have both: the imports and cart validation block, followed by the order creation and inventory loop. The file should start with <?php and end with two closing braces }}.

Here is the complete routes/api.php:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CheckoutController;

Route::apiResource('products', ProductController::class)->only(['index', 'show']);

Route::post('/cart', [CartController::class, 'store']);
Route::get('/cart/{cart_token}', [CartController::class, 'show']);

Route::post('/checkout/{cart_token}', [CheckoutController::class, 'store']);
Trigger the overselling bug

The checkout code works perfectly for a single request. But real stores handle many customers at once. When two processes run the checkout logic at the same time, something breaks.

Here is the problem: both processes read the inventory row, both see enough stock, and both deduct it. Neither process knows the other one is running.

  • Reset your database to a clean state by running this command:
php artisan migrate:fresh --seed

You should see all tables re-created and the seeder output confirming 5 products were inserted.

  • Now set the Wireless Mouse to exactly 1 unit in stock. Start a tinker session by running this command:
php artisan tinker
  • Set the stock to 1 by running this in tinker:
App\Models\Inventory::where('product_id', 1)->update(['quantity' => 1]);

Tinker returns 1 (one row updated). Now simulate what happens when two checkout processes read this inventory at the same time.

  • Load the same inventory row into two separate variables, simulating two concurrent processes:
$processA = App\Models\Inventory::where('product_id', 1)->first();
$processB = App\Models\Inventory::where('product_id', 1)->first();
echo "A reads: " . $processA->quantity . " | B reads: " . $processB->quantity;

Both variables show quantity 1. Each process thinks there is enough stock for one unit.

Before you run the next command, predict what will happen. Both processes passed the stock check. Both are about to decrement from 1 and save. What will the final stock be?

  • Let both processes deduct stock and save by running these commands:
$processA->quantity -= 1;
$processA->save();
$processB->quantity -= 1;
$processB->save();
echo "Final stock: " . App\Models\Inventory::where('product_id', 1)->first()->quantity;

The output reads Final stock: 0. That might look correct at first glance. It is not.

You just sold 2 units from a stock of 1

Both Process A and Process B read quantity as 1. Both passed the stock check. Both decremented 1 to 0 and saved. Process B's write silently overwrote Process A's write with the same value.

In a real store, this means two customers received order confirmations for an item that only existed once. One of them will never get their product. The database shows 0 stock, hiding the fact that you oversold.

This is called a lost update. It happens because the code reads, checks, and writes without any protection against another process doing the same thing in between.

  • Exit tinker by typing:
exit

Why does this happen in production?

In production, a web server handles many requests at the same time across multiple worker processes. Each process has its own memory and its own read of the database. The gap between reading inventory and saving the new value is the window where a second process can slip in and read the same stale data.

The faster the server and the more traffic it handles, the more likely two requests overlap in that window.

You have seen the overselling bug firsthand. The naive read-check-write pattern cannot protect your inventory when multiple processes run at the same time. In the next step, you will fix this with optimistic locking.

Fix It with Optimistic Locking

In the last step, you watched two simultaneous checkouts both succeed on the same last unit of stock.

The overselling bug happened because both processes read the same inventory row, both saw 1 unit available, and both decremented it. Optimistic locking fixes this by checking a version number on the row before writing. If another process changed the row between your read and your write, the update fails cleanly instead of corrupting data.

In this step, get ready to:
  • Understand how the existing version column enables optimistic locking.
  • Rewrite the checkout to use a version-checked conditional update wrapped in a database transaction.
  • Prove the fix works by repeating the concurrent checkout test.
Understand the version column

Your inventories table already has a version column from the original migration you ran in Step 2. Until now, the checkout code has been ignoring it completely.

  • Open database/migrations/2024_01_01_000002_create_inventories_table.php and find this line:
$table->unsignedInteger('version')->default(1);

What is optimistic locking?

Every inventory row starts with version = 1. When a checkout wants to deduct stock, it reads the current version number and includes it in the WHERE clause of the update query.

If the row's version still matches, the update succeeds and bumps the version to 2. If another process already changed the row (bumping the version first), the WHERE clause matches zero rows, and we know the data is stale.

  • Confirm the column exists in your database by running this command in your second terminal:
php artisan tinker --execute="echo App\Models\Inventory::first()->version;"

You should see a number printed (likely 1). The column is in place and ready to use.

Seeing an error about the Inventory model?

Make sure your php artisan serve process is still running in your first terminal. If you stopped it, restart it before running tinker commands.

Help me debug a tinker error with my Inventory model

Rewrite the checkout with version-checked updates

The naive checkout reads inventory, decrements it in PHP, and saves. Two processes doing this at the same time both succeed because neither checks whether the row changed between the read and the write.

You will now replace the checkout logic with a version-checked conditional update wrapped in a DB::transaction.

  • Open app/Http/Controllers/CheckoutController.php.
  • Inside the store method, find this line:
        $order = Order::create(['status' => 'completed', 'total' => 0]);
  • Select everything from that line down to the final ], 201); at the end of the method and delete it.
  • Paste the following code in its place to add the transaction wrapper and version-checked inventory update:
        try {
            // Wrap the entire checkout in a database transaction
            $order = DB::transaction(function () use ($cartItems, $cart) {
                $order = Order::create(['status' => 'completed', 'total' => 0]);
                $total = 0;

                foreach ($cartItems as $cartItem) {
                    $inventory = Inventory::where('product_id', $cartItem->product_id)->first();

                    if (!$inventory || $inventory->quantity < $cartItem->quantity) {
                        throw new \Exception("Insufficient stock for {$cartItem->product->name}");
                    }

                    // Read the current version before updating
                    $currentVersion = $inventory->version;

                    // Only update if version still matches what we read
                    $affected = DB::table('inventories')
                        ->where('id', $inventory->id)
                        ->where('version', $currentVersion)
                        ->update([
                            'quantity' => $inventory->quantity - $cartItem->quantity,
                            'version' => $currentVersion + 1,
                            'updated_at' => now(),
                        ]);

                    // Zero affected rows means another process changed this row
                    if ($affected === 0) {
                        throw new \Exception("Inventory conflict for {$cartItem->product->name}. Please try again.");
                    }

What does this code do?

  • DB::transaction wraps the entire checkout in a single database transaction. If anything throws an exception, all database changes inside the transaction are rolled back automatically.
  • $currentVersion = $inventory->version captures the version number at the moment you read the row.
  • The DB::table('inventories')->where('version', $currentVersion)->update([...]) call only succeeds if the version in the database still matches. It also bumps the version by 1, so any other process holding the old version will fail.
  • If $affected === 0, zero rows were updated. That means the version changed, so we throw an exception to abort the checkout.

Why DB::table instead of Eloquent save?

The naive code used $inventory->save(), which always overwrites the row regardless of what changed since you read it. DB::table lets you add a where('version', $currentVersion) clause so the update only applies when the row is still in the state you expect.

  • Paste the following code directly below the block you just added to complete the foreach loop, close the transaction, and handle errors:
                    // Create the order line item
                    $lineTotal = $cartItem->quantity * $cartItem->product->price;
                    OrderItem::create([
                        'order_id' => $order->id,
                        'product_id' => $cartItem->product_id,
                        'quantity' => $cartItem->quantity,
                        'unit_price' => $cartItem->product->price,
                    ]);

                    $total += $lineTotal;
                }

                // Finalize the order and clean up the cart
                $order->update(['total' => $total]);
                $cart->items()->delete();
                $cart->delete();

                return $order;
            });

            return response()->json([
                'message' => 'Order placed successfully',
                'order_id' => $order->id,
                'total' => $order->total,
            ], 201);
        } catch (\Exception $e) {
            // Route inventory conflicts to 409, other errors to 400
            $statusCode = str_contains($e->getMessage(), 'conflict') ? 409 : 400;
            return response()->json(['message' => $e->getMessage()], $statusCode);
        }

What does this code do?

  • After the version check passes, the rest of the foreach body creates the OrderItem and tallies the total, same as before.
  • The catch block inspects the exception message. If it contains the word "conflict", the response returns a 409 status code. All other errors return 400.
  • Because the checkout is inside DB::transaction, any exception rolls back the entire checkout. No partial orders are created.
  • Save CheckoutController.php.

Seeing a syntax error after saving?

The two code blocks you pasted join inside the foreach loop. Make sure you did not accidentally leave a stray closing brace or delete the opening brace of the foreach. Check the double-check tab below to compare your full file.

Help me fix a syntax error in my CheckoutController

✔️ Awesome, I've got everything!

Make sure you have saved CheckoutController.php.

ⓧ I'd like to double check the full code

Your CheckoutController.php is longer than a single code block, so scroll up and confirm these sections are present in this order:

  • The use imports at the top: Cart, Inventory, Order, OrderItem, JsonResponse, and DB.
  • The cart lookup and empty-cart check (unchanged from before).
  • The try / DB::transaction block with the version-checked DB::table('inventories')->where('version', $currentVersion)->update([...]) call.
  • The OrderItem::create call, cart cleanup, and success response inside the transaction.
  • The catch block that routes conflict exceptions to a 409 response.
Test the fix with simultaneous checkouts

Time to repeat the exact same test from the last step. You will reset the database, set one product to 1 unit of stock, and fire two checkouts at the same time.

  • Reset your database to a clean state by running this command in your second terminal:
php artisan migrate:fresh --seed

You should see all tables created and the seeder run successfully.

  • Set the Wireless Mouse (product 1) to exactly 1 unit of stock by running:
php artisan tinker --execute="App\Models\Inventory::where('product_id', 1)->update(['quantity' => 1, 'version' => 1]);"
  • Confirm the stock is set to 1 by running:
curl -s http://localhost:8000/api/products/1

You should see "stock":1 in the JSON response.

  • Add product 1 to a cart called cart-a by running:
curl -s -X POST http://localhost:8000/api/cart -H "Content-Type: application/json" -d '{"cart_token":"cart-a","product_id":1,"quantity":1}'

You should see "message":"Item added to cart".

  • Add the same product to a second cart called cart-b by running:
curl -s -X POST http://localhost:8000/api/cart -H "Content-Type: application/json" -d '{"cart_token":"cart-b","product_id":1,"quantity":1}'

You should see the same success message for cart-b.

Both carts now hold 1 unit of the Wireless Mouse, but only 1 unit exists in stock. Before you run the concurrent checkouts, what do you think will happen this time? Will both succeed again, or will the version check catch the second one?

  • Fire both checkouts at the same time by running:
curl -s -X POST http://localhost:8000/api/checkout/cart-a & curl -s -X POST http://localhost:8000/api/checkout/cart-b & wait

Windows users

The & operator backgrounds commands in bash. If you are on Windows, run this command in Git Bash or WSL so that both requests fire simultaneously.

You should see two JSON responses printed. One returns "message":"Order placed successfully" with a 201 status. The other returns an inventory conflict message with a 409 status.

What just happened?

Both checkout processes read the inventory row at the same time and saw version = 1. The first process to hit the database updated the row and bumped the version to 2.

When the second process tried its update with WHERE version = 1, the clause matched zero rows because the version was already 2. The transaction rolled back and the API returned a 409 Conflict.

  • Confirm that inventory is correct by running:
curl -s http://localhost:8000/api/products/1

The stock should be exactly 0. Not -1 like before. The database is no longer corrupt.

Both checkouts still succeeding?

Make sure you saved CheckoutController.php after pasting the new code. The development server picks up PHP file changes automatically, but only if the file is saved to disk.

Also check that your where('version', $currentVersion) clause is present in the DB::table('inventories') update call. Without that clause, the update always matches and the lock has no effect.

Help me debug why both checkouts are still succeeding

Seeing a 500 error instead of 409?

Check that your catch (\Exception $e) block is present outside the DB::transaction closure. The exception thrown inside the transaction must be caught by the outer try/catch to return a clean JSON response.

Help me debug a 500 error in my optimistic locking checkout

Your e-commerce API is now concurrency-safe. No matter how many checkout requests arrive at the same time, the version check ensures that only one can claim the last unit of stock. Ready for a bonus challenge? The Secret Mission will push this pattern further.

Secret mission

Add Order Cancellation with Optimistic Locking

What happens when a customer wants to cancel their order? The inventory needs to come back. But restoring stock has the same concurrency risk as deducting it. Build a cancellation endpoint that safely restores inventory using the optimistic locking pattern you just learned.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • A MySQL database named ecommerce_api with tables for products, inventories, carts, cart_items, orders, and order_items.
  • Your Laravel project folder on your Desktop.
  • The php artisan serve development server (if still running).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with the API further.

  • Your MySQL database and project folder stay as they are.
  • If php artisan serve is still running, you can keep hitting your API endpoints with curl.

Pause - I'll come back to this later

Shut down the running development server to free up your terminal. Your database and project files persist on disk.

  • Switch to the terminal where php artisan serve is running and press Ctrl+C to stop it.
  • When you are ready to resume, open a terminal in your project folder and run php artisan serve again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your database and project folder permanently.

  • If php artisan serve is still running, press Ctrl+C in its terminal to stop it.
  • Drop the MySQL database by running this command in a MySQL session:
mysql -u root -p -e "DROP DATABASE ecommerce_api;"
  • Delete your project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf ecommerce-api

On Windows, run:

cd ~\Desktop
rmdir /s /q ecommerce-api

What about the folder name?

Replace ecommerce-api with whatever you named your project folder in Step 1. If you are unsure, run ls ~/Desktop (macOS/Linux) or dir ~\Desktop (Windows) to find it.

Nice Work!

Nice Work!

Nice work! You just built a concurrency-safe e-commerce API from scratch using Laravel 8 and MySQL.

You've learned how to:

  • Built a full JSON API with five Eloquent models, migrations, seeders, and relationships (hasOne, hasMany, belongsTo) to power product browsing, cart management, and checkout.
  • Discovered the overselling race condition first-hand by simulating two simultaneous checkouts and watching inventory go negative.
  • Fixed it with optimistic locking using a version column and conditional updates so the second buyer gets a clean 409 Conflict instead of a corrupt database.
  • Secret Mission: Added an order cancellation endpoint that safely restores inventory using the same optimistic locking pattern.

Ready to quiz yourself?