Build a Reliable Laravel Order API

Build an idempotent Laravel order API with Redis queue processing.

Introduction

30 Second Summary

When a checkout screen hangs, a customer may tap the button again. Without protection, that retry can create two orders for one purchase.

In this project, you will build a Laravel JSON order API with idempotency based on a stable request_id. Feature tests will prove that each new order reaches a Redis queue only after its database commit.

What You'll Build

Your demo shows one order moving from pending to processed even when the same request_id reaches the API twice.

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

  • A retry-safe order API that returns 201 for the first request. A retry with the same request_id returns the original order with 200.
  • A Redis-backed worker that processes new orders after their transactions commit. You can watch each order move from pending to processed.
  • An executable feature suite that proves invalid input receives 422. It proves duplicate requests create one row. It proves the queue receives one job.
  • Secret Mission: An expiring overlap lock that prevents two workers from processing the same order simultaneously.

Are there any prerequisites?

Start with an existing Laravel Sail project on Linux. Docker must already be running.

Before We Start

Before the hands-on work begins, lock in the reliability promise for your JSON order API. A stable client-provided request_id lets each retry return the original order instead of creating dangerous duplicate writes or processing jobs.

Prepare the Sail Environment

A reliable order API needs a reproducible application runtime. Its database and queue backend must also be observable.

This step prepares Laravel Sail on Docker. MySQL stores the orders. Redis carries the processing jobs.

In this step, get ready to:
  • Open the existing Laravel project in Visual Studio Code.
  • Configure Sail with MySQL and Redis.
  • Prove the Sail toolchain works from the terminal.
Open the Laravel project in Visual Studio Code

Your editor keeps the Sail configuration beside the code that depends on it. Opening the existing project folder also gives you one place to inspect compose.yaml and .env.

  • Use a terminal whose current location is your existing Laravel project folder.
  • Try opening that folder in Visual Studio Code by running this command:
code .

The period tells Visual Studio Code to use the terminal's current folder. Choose the tab that matches what happens.

✔️ Visual Studio Code opens

You should see the Laravel project files in the Explorer sidebar. The folder is ready for the configuration checks ahead.

ⓧ Visual Studio Code is missing

Use the Snap installation when Snap is available on your Linux system. The official Linux guide covers Debian, RPM, openSUSE, and Nix installations.

  • Install Visual Studio Code with Snap by running this command if Snap is available:
sudo snap install --classic code

Snap installs Visual Studio Code with classic confinement. This gives the editor access to files throughout your Laravel project.

code .

You should now see the Laravel project files in the Explorer sidebar.

Visual Studio Code still not opening?

Confirm the installation completed before retrying the command. A distribution-specific package may require you to start a new terminal session.

Ask for help to debug the Visual Studio Code installation.

Add MySQL and Redis to Sail

The compose.yaml file defines the containers that belong to the project. Sail can add MySQL or Redis when an existing project does not include them.

  • Select compose.yaml in the Visual Studio Code Explorer sidebar.
  • Search compose.yaml for the mysql service.
  • Search compose.yaml for the redis service.

Leave compose.yaml unchanged when both services already exist. Use Sail's service selector when either service is missing.

The selector is interactive. It asks which supported services should be added to the existing Docker environment.

  • Return to the terminal in your Laravel project folder if either service is missing.
  • Start the Sail service selector by running this command:
php artisan sail:add

What does this command do?

The command adds your selected service definitions to the existing Sail environment. It keeps this project inside one local Docker network.

  • Select MySQL from the interactive service list if the MySQL service was missing.
  • Select Redis from the interactive service list if the Redis service was missing.
  • Return to compose.yaml in Visual Studio Code after the command finishes.
  • Confirm that compose.yaml now includes the mysql service.
  • Confirm that compose.yaml now includes the redis service.

Service selector failed?

Make sure the terminal is inside the Laravel folder that contains artisan and compose.yaml.

Ask for help to debug the Sail service setup.

The application reaches these containers through Docker service names. The queue connection also needs Redis selected as its backend.

  • Select .env in the Visual Studio Code Explorer sidebar.
  • Replace the existing database and queue connection lines with these values:
DB_HOST=mysql
REDIS_HOST=redis
QUEUE_CONNECTION=redis

What do these settings control?

  • The DB_HOST=mysql setting sends database traffic to the Sail MySQL service.
  • The REDIS_HOST=redis setting sends Redis traffic to the Sail Redis service.
  • The QUEUE_CONNECTION=redis setting stores queued jobs in Redis.
  • Save .env.

✔️ Awesome, I've got everything!

Great. Your .env file now points the application at the MySQL and Redis containers.

ⓧ I'd like to double check the full code

Compare these connection settings with the matching lines in .env.

DB_HOST=mysql
REDIS_HOST=redis
QUEUE_CONNECTION=redis
Start Sail and verify the toolchain

Sail turns the service definitions into running containers. PHP executes inside the application container with access to the MySQL and Redis services.

The first container start can take a few minutes while Docker downloads any missing images. Quiet terminal output during that download does not mean the process is stuck.

  • Start the Sail containers in the background by running this command:
sail up -d

What does detached mode do?

The command starts the project containers without keeping the terminal occupied. You can continue running verification commands from the same terminal.

Containers did not start?

Confirm Docker is running on Linux. Check that compose.yaml contains both service definitions before retrying Sail.

Ask for help to debug the Sail container startup.

  • Check the PHP version inside the running Sail container by running this command:
sail php --version

This reads the runtime from the application container. Choose the tab that matches the result.

✔️ I see the required PHP version

You should see output beginning with PHP 8.5. That confirms the project is using the required runtime.

ⓧ I see an older PHP version

An older container runtime does not match this project's PHP requirement. Update the Sail runtime before continuing.

  • Follow the Laravel Sail documentation to select PHP 8.5 for the application container.
  • Return to the Laravel project terminal after updating the runtime.
  • Repeat the version check by running:
sail php --version

Continue when the output begins with PHP 8.5.

ⓧ The sail command is missing

The project has Sail installed without a working terminal alias. Configure the alias before continuing with the remaining commands.

  • Follow the alias instructions in the Laravel Sail documentation.
  • Return to the Laravel project terminal after configuring the alias.
  • Repeat the version check by running:
sail php --version

You should now see the PHP runtime version from the Sail container.

  • Confirm Sail can run Artisan by listing the available commands:
./vendor/bin/sail artisan list

You should see the Artisan command list. This proves the Sail launcher can reach the Laravel application.

  • Run the existing database migrations against MySQL by running:
sail artisan migrate

You should see the migrations complete without a database connection failure. MySQL is now reachable from Laravel.

Migrations cannot reach MySQL?

Check that DB_HOST=mysql is saved in .env. Confirm the MySQL container started with the rest of the Sail environment.

Ask for help to debug the Sail MySQL connection.

  • Open Laravel Tinker inside Sail by running this command:
sail tinker

You should see the interactive Tinker prompt. Its appearance confirms Laravel Tinker is available inside the container.

  • Exit the Tinker session once the prompt appears.

Before you run the starter suite, do you expect every existing test to pass? Keep the answer in mind.

  • Run the starter test suite inside Sail by running this command:
sail test

You should see the starter tests complete with no failures. That is the environment proven from PHP through MySQL and Laravel's test runner.

Starter tests are failing?

Separate environment failures from application test failures. Recheck the PHP version and migration result before changing any project code.

Ask for help to debug the Sail test environment.

Your Sail environment is reproducible and observable. Next up, you will build the first working order endpoint and expose its duplicate-write weakness.

Build the Naive Order Endpoint

Your Laravel Sail environment is ready with MySQL and Redis. The next goal is a JSON endpoint that can accept an order.

The first version uses ordinary create logic for every HTTP request. A retry gives that logic the same payload again.

This step tests whether the database stays correct when the same request reaches the endpoint twice.

In this step, get ready to:
  • Create the orders table and Order model.
  • Add a JSON endpoint that validates order data.
  • Run a duplicate-request test that exposes the reliability gap.
Create the order storage layer

An order needs a database shape before the endpoint can persist it. A migration records that shape as executable PHP code.

  • In the Visual Studio Code Explorer sidebar, expand database/migrations.
  • Create 2026_10_25_000000_create_orders_table.php inside database/migrations with this code:
<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->string('request_id', length: 64);
            $table->string('customer_email');
            $table->unsignedInteger('total_cents');
            $table->string('status')->default('pending');
            $table->timestamps();
        });
    }

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

What Does This Migration Define?

  • The id column gives each order its database identity.
  • The request_id column stores the stable identifier supplied by the client.
  • The total_cents column stores money as an integer number of cents.
  • The status column starts every order at pending.
  • The timestamps record when Laravel creates or updates each order.
  • Save 2026_10_25_000000_create_orders_table.php.
  • Apply the new migration by running this command:
sail artisan migrate

What Does This Command Prove?

Laravel sends the migration to the running MySQL service. A successful result confirms that the orders table now exists.

You should see the orders migration complete successfully.

Migration Did Not Complete?

Confirm that the Sail containers are still running. Check that your .env file still uses DB_HOST=mysql.

If the migration still fails, ask for help diagnosing the Sail database connection.

The Order model gives the endpoint a PHP object for creating rows in the new table. Its fillable fields define which order values can be passed into mass-assignment methods.

  • In the Visual Studio Code Explorer sidebar, expand app/Models.
  • Create Order.php inside app/Models with this code:
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable([
    'request_id',
    'customer_email',
    'total_cents',
    'status',
])]
class Order extends Model
{
}

What Does This Model Do?

The model maps the App\Models\Order class to the orders table. The Fillable attribute permits the four values used when the endpoint creates an order.

  • Save app/Models/Order.php.
  • Confirm that Order.php appears under app/Models in the Explorer sidebar.

Order Model Missing From Explorer?

Check that you created the file inside app/Models. Confirm that the filename uses a capital O because Linux paths are case-sensitive.

If the file is still hard to locate, ask for help finding the Order model.

Wire the order API route

The API route is the boundary between a client request and the Order model. It validates the incoming fields before writing a row.

  • In the Visual Studio Code Explorer sidebar, expand the routes folder.
  • Create api.php inside routes with this opening section:
<?php

declare(strict_types=1);

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

What Do These Imports Provide?

  • The Order model writes validated data to the orders table.
  • The Request object provides the incoming JSON payload.
  • The Route facade registers the endpoint.
  • Add the order route below the imports in routes/api.php using this code:
Route::post('/orders', function (Request $request) {
    $validated = $request->validate([
        'request_id' => ['required', 'string', 'max:64'],
        'customer_email' => ['required', 'email'],
        'total_cents' => ['required', 'integer', 'min:1'],
    ]);

    $order = Order::create([
        'request_id' => $validated['request_id'],
        'customer_email' => $validated['customer_email'],
        'total_cents' => $validated['total_cents'],
        'status' => 'pending',
    ]);

    return response()->json([
        'data' => [
            'id' => $order->id,
            'request_id' => $order->request_id,
            'customer_email' => $order->customer_email,
            'total_cents' => $order->total_cents,
            'status' => $order->status,
        ],
    ], 201);
});

How Does the Endpoint Work?

  • The route accepts a POST request at /orders.
  • The validation rules require a request identifier.
  • The validation rules require a valid customer email.
  • The validation rules require a positive integer total.
  • The Order::create call inserts one row for each accepted request.
  • The response returns the saved order as JSON with status 201.
  • Save routes/api.php.
  • Check that the existing application remains healthy by running the test suite:
sail test

What Does This Check Cover?

The existing tests exercise the application after the new route file is added. They should still complete without a PHP syntax failure.

You should see the starter tests complete successfully.

The new route file now needs to be included in Laravel's routing configuration. The api entry gives routes in this file the /api prefix.

  • Select bootstrap/app.php in the Visual Studio Code Explorer sidebar.
  • Replace the contents of bootstrap/app.php with this code:
<?php

declare(strict_types=1);

use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->create();

How Is API Routing Registered?

The withRouting configuration now loads routes/api.php. Laravel applies the API prefix to the order route.

The endpoint is therefore available at /api/orders.

  • Save bootstrap/app.php.
  • Verify that Laravel can boot with the registered API route by running the test suite:
sail test

What Does This Verification Prove?

The test command boots Laravel with the updated routing configuration. A successful starter suite confirms that Laravel can load the new API file.

Good progress. Your application now boots with POST /api/orders registered.

Application Fails to Boot?

Check that the API path in bootstrap/app.php is exactly __DIR__.'/../routes/api.php'. Confirm that routes/api.php exists.

If Laravel still cannot load the route, ask for help checking the routing files.

Expose the duplicate-write failure

A feature test can send requests through the same route that a real client uses. This test repeats one payload before checking the final database count.

The DatabaseMigrations trait gives the test a fresh schema. That keeps the result focused on the two requests in this test.

  • In the Visual Studio Code Explorer sidebar, expand tests/Feature.
  • Create OrderCreationTest.php inside tests/Feature with this code:
<?php

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_repeated_request_creates_only_one_order(): void
    {
        $payload = [
            'request_id' => 'checkout-2026-0001',
            'customer_email' => 'developer@example.com',
            'total_cents' => 2500,
        ];

        $this->postJson('/api/orders', $payload)->assertCreated();
        $this->postJson('/api/orders', $payload)->assertCreated();

        $this->assertDatabaseCount('orders', 1);
    }
}

What Does This Test Measure?

  • The payload uses one stable request_id for both requests.
  • Each postJson call sends that payload to /api/orders.
  • Each assertCreated check confirms that the endpoint reaches its 201 response.
  • The final assertion expects the orders table to contain exactly one row.
  • Save tests/Feature/OrderCreationTest.php.

Before you run the suite, predict whether ordinary create logic will recognize the second request as a retry.

  • Run the complete test suite to reveal the endpoint behavior:
sail test

What Does This Run Exercise?

The test suite rebuilds the test schema before sending both JSON requests. It checks the HTTP responses before counting the resulting order rows.

The endpoint assertions reach 201. The final database-count assertion fails.

The output reports that two order rows exist when the test expected one. You found the intended reliability gap.

Seeing a Different Failure?

If an endpoint assertion fails first, compare routes/api.php with the checkpoint below. Check that bootstrap/app.php registers the API file.

If the test cannot find the orders table, confirm that the migration filename and table name match the checkpoint. For another result, ask for help diagnosing the feature test.

✔️ Awesome, I've got everything!

You have reached the intended baseline. Keep the failing duplicate-count assertion because it captures the reliability problem for the next step.

Your .env configuration still uses DB_HOST=mysql, REDIS_HOST=redis, and QUEUE_CONNECTION=redis.

Your Laravel Sail containers remain running with MySQL and Redis available.

ⓧ I'd like to double check the full code

The complete app/Models/Order.php file appears below for comparison:

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable([
    'request_id',
    'customer_email',
    'total_cents',
    'status',
])]
class Order extends Model
{
}

Order Model Checkpoint

The model permits the four values used by the endpoint's create operation.

The complete bootstrap/app.php file appears below for comparison:

<?php

declare(strict_types=1);

use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->create();

Routing Checkpoint

The routing configuration includes routes/api.php.

The complete database/migrations/2026_10_25_000000_create_orders_table.php file appears below for comparison:

<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->string('request_id', length: 64);
            $table->string('customer_email');
            $table->unsignedInteger('total_cents');
            $table->string('status')->default('pending');
            $table->timestamps();
        });
    }

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

Migration Checkpoint

The schema stores the request identifier as a regular string in this baseline version.

The complete routes/api.php file appears below for comparison:

<?php

declare(strict_types=1);

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

Route::post('/orders', function (Request $request) {
    $validated = $request->validate([
        'request_id' => ['required', 'string', 'max:64'],
        'customer_email' => ['required', 'email'],
        'total_cents' => ['required', 'integer', 'min:1'],
    ]);

    $order = Order::create([
        'request_id' => $validated['request_id'],
        'customer_email' => $validated['customer_email'],
        'total_cents' => $validated['total_cents'],
        'status' => 'pending',
    ]);

    return response()->json([
        'data' => [
            'id' => $order->id,
            'request_id' => $order->request_id,
            'customer_email' => $order->customer_email,
            'total_cents' => $order->total_cents,
            'status' => $order->status,
        ],
    ], 201);
});

Endpoint Checkpoint

The route validates the payload before calling Order::create directly.

The complete tests/Feature/OrderCreationTest.php file appears below for comparison:

<?php

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_repeated_request_creates_only_one_order(): void
    {
        $payload = [
            'request_id' => 'checkout-2026-0001',
            'customer_email' => 'developer@example.com',
            'total_cents' => 2500,
        ];

        $this->postJson('/api/orders', $payload)->assertCreated();
        $this->postJson('/api/orders', $payload)->assertCreated();

        $this->assertDatabaseCount('orders', 1);
    }
}

Feature Test Checkpoint

The test sends the same payload twice before asserting that only one order should exist.

Your .env configuration remains set to DB_HOST=mysql, REDIS_HOST=redis, and QUEUE_CONNECTION=redis.

Your Laravel Sail containers remain running with MySQL and Redis available.

You now have a working endpoint plus a test that proves its retry behavior is unsafe. Next, you will make order creation idempotent at the transaction and database layers.

Make Order Creation Idempotent

Your Laravel endpoint accepts valid orders. Its retry test now exposes two rows for one checkout.

That duplicate-write failure proves application validation cannot protect a cross-request invariant. A MySQL unique constraint will enforce idempotency at the storage layer.

A transaction-backed action class will create or retrieve one order inside a single boundary. The route can then choose the correct response for a new order or a retry.

In this step, get ready to:
  • Enforce unique request IDs in the database.
  • Move order creation into a transaction-backed action.
  • Return distinct responses for new orders and retries.
Enforce a Unique Request ID

The database needs a rule that every code path must obey. Making request_id unique turns duplicate prevention into a schema invariant.

  • Return to database/migrations/2026_10_25_000000_create_orders_table.php in the editor from earlier.
  • Find the current request_id column definition.
  • Replace that definition with this line:
$table->string('request_id', length: 64)->unique();

What Changed?

  • The column still accepts request IDs up to 64 characters.
  • The unique() modifier instructs MySQL to reject a second row with the same value.
  • This rule protects the table even when another code path writes orders.
  • Save database/migrations/2026_10_25_000000_create_orders_table.php.

Rebuilding the schema deletes the local rows from your earlier duplicate demonstration. Those rows are disposable project data.

  • Rebuild the local schema with the unique constraint by running:
sail artisan migrate:fresh

What Does This Command Do?

The command drops the existing tables. It then reruns every migration against the local MySQL database.

The rebuilt orders table now enforces one row per request_id.

You should see the migrations complete successfully. The orders migration should appear in the completed list.

Migration Rebuild Failed?

  • Confirm the Sail containers from earlier are still running.
  • Check that unique() appears before the final semicolon on the request_id line.
  • Compare each brace in the migration with the full file below.

Help me diagnose the migration failure.

✔️ Awesome, I've got everything!

Great. Your rebuilt orders table now enforces unique request IDs.

ⓧ I'd like to double check the full code

Compare your migration with this complete file:

<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->string('request_id', length: 64)->unique();
            $table->string('customer_email');
            $table->unsignedInteger('total_cents');
            $table->string('status')->default('pending');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};
Create the Transaction-Backed Action

A dedicated action gives order creation one reusable boundary. DB::transaction() also retries the operation up to three times when a deadlock interrupts it.

Inside that boundary, Order::firstOrCreate() searches by request_id. It creates a pending order only when no matching row exists.

  • Select the app folder in the editor's Explorer sidebar.
  • Use the folder control to create Actions inside app.

You should see the new app/Actions folder in the Explorer sidebar.

  • Select the new Actions folder.
  • Use the file control to create CreateOrder.php.

You should see app/Actions/CreateOrder.php open as an empty file.

  • Replace the contents of app/Actions/CreateOrder.php with this action:
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Order;
use Illuminate\Support\Facades\DB;

class CreateOrder
{
    /**
     * @param array{request_id: string, customer_email: string, total_cents: int} $data
     */
    public function handle(array $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            return Order::firstOrCreate(
                ['request_id' => $data['request_id']],
                [
                    'customer_email' => $data['customer_email'],
                    'total_cents' => $data['total_cents'],
                    'status' => 'pending',
                ],
            );
        }, attempts: 3);
    }
}

What Does This Action Do?

  • The handle() method accepts the validated order fields.
  • The transaction keeps the lookup and possible insert inside one retryable operation.
  • The first array passed to firstOrCreate() identifies an existing order by request_id.
  • The second array supplies the values used only when a new order is needed.
  • The attempts: 3 argument allows two retries after an initial deadlock.
  • Save app/Actions/CreateOrder.php.

You should see CreateOrder.php listed inside the app/Actions folder.

Action File in the Wrong Place?

  • Confirm the complete path is app/Actions/CreateOrder.php.
  • Check that the namespace is App\Actions.
  • Check that the class name matches the filename exactly.

Help me fix the action class location.

✔️ Awesome, I've got everything!

Your transaction-backed order action is saved in the correct folder.

ⓧ I'd like to double check the full code

Compare your action with this complete file:

<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Order;
use Illuminate\Support\Facades\DB;

class CreateOrder
{
    /**
     * @param array{request_id: string, customer_email: string, total_cents: int} $data
     */
    public function handle(array $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            return Order::firstOrCreate(
                ['request_id' => $data['request_id']],
                [
                    'customer_email' => $data['customer_email'],
                    'total_cents' => $data['total_cents'],
                    'status' => 'pending',
                ],
            );
        }, attempts: 3);
    }
}
Return the Existing Order on Retry

The route can receive CreateOrder through dependency injection. The action result carries wasRecentlyCreated so the route can distinguish the first request from a retry.

  • Replace the contents of routes/api.php with this updated route:
<?php

declare(strict_types=1);

use App\Actions\CreateOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/orders', function (Request $request, CreateOrder $createOrder) {
    $validated = $request->validate([
        'request_id' => ['required', 'string', 'max:64'],
        'customer_email' => ['required', 'email'],
        'total_cents' => ['required', 'integer', 'min:1'],
    ]);

    $order = $createOrder->handle($validated);

    return response()->json([
        'created' => $order->wasRecentlyCreated,
        'data' => [
            'id' => $order->id,
            'request_id' => $order->request_id,
            'customer_email' => $order->customer_email,
            'total_cents' => $order->total_cents,
            'status' => $order->status,
        ],
    ], $order->wasRecentlyCreated ? 201 : 200);
});

How Does the Route Handle Retries?

  • The route receives CreateOrder from the application container.
  • The validated payload passes into handle().
  • A new order sets created to true.
  • A retry sets created to false.
  • The response status is 201 for creation. It is 200 for retrieval.
  • Save routes/api.php.

✔️ Awesome, I've got everything!

Your API route now delegates order creation to the action.

ⓧ I'd like to double check the full code

Compare your route with this complete file:

<?php

declare(strict_types=1);

use App\Actions\CreateOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/orders', function (Request $request, CreateOrder $createOrder) {
    $validated = $request->validate([
        'request_id' => ['required', 'string', 'max:64'],
        'customer_email' => ['required', 'email'],
        'total_cents' => ['required', 'integer', 'min:1'],
    ]);

    $order = $createOrder->handle($validated);

    return response()->json([
        'created' => $order->wasRecentlyCreated,
        'data' => [
            'id' => $order->id,
            'request_id' => $order->request_id,
            'customer_email' => $order->customer_email,
            'total_cents' => $order->total_cents,
            'status' => $order->status,
        ],
    ], $order->wasRecentlyCreated ? 201 : 200);
});

This is an intentional contract checkpoint. The updated route now describes retry behavior differently from the old test.

Before you run the suite, do you think the old response assertions can still pass?

  • Expose the outdated response assertion by running:
sail test

Why Does the Test Fail Now?

The test still calls assertCreated() on both requests. The updated endpoint returns a successful retrieval response for the retry.

This failure shows that the route contract changed. The next edit makes the test prove the new contract.

  • Return to tests/Feature/OrderCreationTest.php.
  • Select the entire current test_repeated_request_creates_only_one_order() method.
  • Replace the selected method with this test:
public function test_repeated_request_is_idempotent(): void
{
    $payload = [
        'request_id' => 'checkout-2026-0001',
        'customer_email' => 'developer@example.com',
        'total_cents' => 2500,
    ];

    $firstResponse = $this->postJson('/api/orders', $payload);
    $retryResponse = $this->postJson('/api/orders', $payload);

    $firstResponse->assertCreated()->assertJsonPath('created', true);
    $retryResponse->assertOk()->assertJsonPath('created', false);

    $this->assertSame($firstResponse['data']['id'], $retryResponse['data']['id']);
    $this->assertDatabaseCount('orders', 1);
}

What Does the Test Prove?

  • The first request must return a creation response.
  • The retry must return a successful retrieval response.
  • The created field must reveal which path occurred.
  • Both responses must contain the same order ID.
  • The database must contain exactly one order.
  • Save tests/Feature/OrderCreationTest.php.

✔️ Awesome, I've got everything!

Your feature test now describes the complete idempotency contract.

ⓧ I'd like to double check the full code

Compare your test with this complete file:

<?php

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_repeated_request_is_idempotent(): void
    {
        $payload = [
            'request_id' => 'checkout-2026-0001',
            'customer_email' => 'developer@example.com',
            'total_cents' => 2500,
        ];

        $firstResponse = $this->postJson('/api/orders', $payload);
        $retryResponse = $this->postJson('/api/orders', $payload);

        $firstResponse->assertCreated()->assertJsonPath('created', true);
        $retryResponse->assertOk()->assertJsonPath('created', false);

        $this->assertSame($firstResponse['data']['id'], $retryResponse['data']['id']);
        $this->assertDatabaseCount('orders', 1);
    }
}

Before you run the final check, do you expect the duplicate request to create another row?

  • Verify the idempotency contract by running:
sail test

What Does the Passing Suite Prove?

  • The first request receives status 201.
  • The retry receives status 200.
  • Both responses contain the same order ID.
  • The database contains one order.
  • The suite completes without failures.

That closes the duplicate-write gap. Your API now recognizes a repeated checkout request without creating another order.

Tests Still Failing?

  • Confirm the migration uses unique() on request_id.
  • Confirm the route calls $createOrder->handle($validated).
  • Confirm the test expects the retry response to be successful without expecting creation.

Help me debug the idempotency test.

Order intake is now safe across client retries. Next, you will queue processing only after the database transaction commits.

Process Orders After Commit

Your endpoint now enforces idempotency across request retries. The next reliability boundary begins when that order leaves the request cycle.

A queue worker can receive a job before MySQL commits the order transaction. You will use Laravel's after-commit dispatch so Redis receives work only after the row is durable.

In this step, get ready to:
  • Define a queued job that marks an order as processed.
  • Dispatch one job after the order transaction commits.
  • Run a Redis worker and verify the processed order status.
Create the queued processing job

A queued job is a serializable instruction that a worker can handle outside the HTTP request. ProcessOrder receives an Order model and changes its status when the worker handles it.

  • In Visual Studio Code, create app/Jobs/ProcessOrder.php by pasting the queued job below into a new file:
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order)
    {
    }

    public function handle(): void
    {
        $this->order->update([
            'status' => 'processed',
        ]);
    }
}

What does this job do?

  • The ShouldQueue contract tells Laravel to send this work through the configured queue.
  • The Queueable trait provides the job behavior needed for dispatch and model serialization.
  • The #[Tries(3)] attribute allows the worker to attempt the job up to three times.
  • The handle() method updates the stored order status to processed.
  • Save app/Jobs/ProcessOrder.php.
  • Confirm ProcessOrder.php is listed inside app/Jobs in Visual Studio Code.

Cannot find the job file?

Check that ProcessOrder.php is inside app/Jobs. Confirm the namespace at the top of the file is App\Jobs.

Ask for help to check my ProcessOrder file path and namespace.

✔️ Awesome, I've got everything!

Great. Double check that you saved app/Jobs/ProcessOrder.php before wiring it into order creation.

ⓧ I'd like to double check the full code

Compare your complete app/Jobs/ProcessOrder.php file with this reference:

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order)
    {
    }

    public function handle(): void
    {
        $this->order->update([
            'status' => 'processed',
        ]);
    }
}
Dispatch the job after commit

The transaction already decides whether an order is new or a retry. That same boundary can dispatch processing only for a newly inserted order.

  • In app/Actions/CreateOrder.php, replace the current import group with these imports:
use App\Jobs\ProcessOrder;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

What changes here?

The new ProcessOrder import makes the queued job available to the action. The existing model and database imports stay in place.

  • Save app/Actions/CreateOrder.php.
  • Confirm the new import resolves by running the existing test suite:
sail test

What does this check prove?

The passing suite confirms that Laravel can load the new import. It also confirms that the existing idempotency behavior remains intact.

Tests cannot load ProcessOrder?

Compare the imported App\Jobs\ProcessOrder namespace with the namespace inside app/Jobs/ProcessOrder.php. Check the capitalization of both Jobs and ProcessOrder.

Ask for help to debug the ProcessOrder import.

  • In handle(), replace the transaction callback's direct return with this transaction body:
$order = Order::firstOrCreate(
    ['request_id' => $data['request_id']],
    [
        'customer_email' => $data['customer_email'],
        'total_cents' => $data['total_cents'],
        'status' => 'pending',
    ],
);

if ($order->wasRecentlyCreated) {
    ProcessOrder::dispatch($order)->afterCommit();
}

return $order;

Why dispatch inside the transaction?

  • The wasRecentlyCreated check prevents a retry from dispatching another job.
  • The afterCommit() call holds the job until the surrounding transaction commits successfully.
  • The final return $order keeps the action's existing response contract intact.
  • Save app/Actions/CreateOrder.php.
  • Verify the idempotent order flow still passes by running:
sail test

What should you see?

You should see the existing duplicate-request test pass with no failures. One order remains responsible for one processing dispatch.

Seeing a test failure?

Confirm that $order receives the result of Order::firstOrCreate(). Make sure the dispatch block appears before return $order.

Ask for help to debug my after-commit dispatch.

✔️ Awesome, I've got everything!

Your action now dispatches processing only for a newly created order. Double check that app/Actions/CreateOrder.php is saved.

ⓧ I'd like to double check the full code

Compare your complete app/Actions/CreateOrder.php file with this reference:

<?php

declare(strict_types=1);

namespace App\Actions;

use App\Jobs\ProcessOrder;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class CreateOrder
{
    /**
     * @param array{request_id: string, customer_email: string, total_cents: int} $data
     */
    public function handle(array $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $order = Order::firstOrCreate(
                ['request_id' => $data['request_id']],
                [
                    'customer_email' => $data['customer_email'],
                    'total_cents' => $data['total_cents'],
                    'status' => 'pending',
                ],
            );

            if ($order->wasRecentlyCreated) {
                ProcessOrder::dispatch($order)->afterCommit();
            }

            return $order;
        }, attempts: 3);
    }
}
Process a demo order with Redis

A real worker proves that the queue boundary reaches beyond application code. You will use Laravel Tinker to prepare one pending order before sending its job to Redis.

  • Return to the terminal from earlier and start Tinker by running:
sail tinker

What opens here?

Tinker opens an interactive PHP session inside the running Sail application. Commands entered here can read models and dispatch jobs through the configured services.

  • Create or retrieve the demo order and inspect its starting status by entering:
$order = App\Models\Order::firstOrCreate(
    ['request_id' => 'checkout-2026-0001'],
    [
        'customer_email' => 'developer@example.com',
        'total_cents' => 2500,
        'status' => 'pending',
    ],
);

$order->status;

What should you see?

The final expression should show pending for the demo order. That starting value makes the worker's update visible.

  • Send the demo order to the configured Redis queue by entering:
Bus::dispatch(new App\Jobs\ProcessOrder($order));

What does this dispatch do?

The dispatch places a serialized ProcessOrder job onto Redis. The order remains pending until a worker handles that job.

  • Exit the current Laravel Tinker session.

Before you start the worker, do you expect the demo order to remain pending or become processed? Keep your prediction in mind.

  • Process the queued demo job by running:
sail artisan queue:work

What does the worker do?

The worker reserves the queued job from Redis. It runs ProcessOrder::handle() against the committed MySQL order.

The worker stays active after processing the job because it waits for more work. Stop it after the terminal reports successful processing.

You should see the worker report that the queued job completed successfully.

  • Stop the worker after it reports successful processing.

Before you inspect the database result, which status do you expect Tinker to return? Keep your prediction in mind.

  • Start a fresh Tinker session by running:
sail tinker

Why use a fresh session?

The fresh session retrieves the order after the separate worker process has finished. This makes the database value the source of truth for the check.

  • Retrieve the demo order and refresh its status by entering:
$order = App\Models\Order::firstOrCreate(
    ['request_id' => 'checkout-2026-0001'],
    [
        'customer_email' => 'developer@example.com',
        'total_cents' => 2500,
        'status' => 'pending',
    ],
);

$order->refresh()->status;

What should you see?

Tinker should return processed. This confirms that the Redis worker received the committed order and updated its MySQL row.

That is the risky boundary secured: your worker has processed a committed order from Redis.

Order still pending?

  • Return to the terminal session that ran the worker and confirm it reported job completion.
  • Check that QUEUE_CONNECTION=redis remains configured in .env.
  • Confirm both Tinker sessions used the request ID checkout-2026-0001.

Ask for help to debug my pending Redis queue job.

Your transaction and queue now meet at a safe commit boundary. Next, you will protect that behavior with feature tests for validation and single dispatch.

Lock the Contract Down With Tests

Your worker now processes a new order only after the database commit. The flow begins with HTTP before writing through a MySQL transaction.

The work continues across a Redis queue after commit. A future refactor could accidentally restore duplicate rows or duplicate jobs.

Laravel feature tests turn these behaviors into an executable contract. This step adds repeatable checks for responses, database state, validation, plus job dispatch.

In this step, get ready to:
  • Isolate queue dispatches with a queue fake.
  • Prove one request ID produces one order plus one processing job.
  • Prove missing input returns validation errors without dispatching work.
Isolate the queue during the idempotency test

An automated test needs to observe dispatch behavior without handing jobs to Redis. Queue::fake() records queued jobs so the test can inspect every dispatch.

  • Return to tests/Feature/OrderCreationTest.php in the Visual Studio Code Explorer sidebar.
  • Replace the file's opening section through the $payload array with this code:
<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Jobs\ProcessOrder;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_repeated_request_is_idempotent_and_dispatches_one_job(): void
    {
        Queue::fake();

        $payload = [
            'request_id' => 'checkout-2026-0001',
            'customer_email' => 'developer@example.com',
            'total_cents' => 2500,
        ];

What does this setup control?

  • The DatabaseMigrations trait preserves the real migration lifecycle needed by after-commit callbacks.
  • The Queue::fake() call intercepts jobs before they reach Redis.
  • The ProcessOrder import gives the test an exact job class to inspect.
  • The updated method name states that idempotency includes single dispatch behavior.
  • Save tests/Feature/OrderCreationTest.php.
  • Verify the queue-isolated test setup by running this command in the terminal from earlier:
sail test

What should I see?

The current idempotency test should pass. Good work. The test now controls the queue boundary without adding another job to Redis.

Tests failing after the setup change?

Check that both new imports sit below the namespace declaration. Confirm that Queue::fake() appears before either API request.

Help me fix the queue-fake test setup.

Assert the complete idempotency contract

The endpoint contract covers more than the number of database rows. A retry must return the same ID while preserving the response semantics of the original request.

The new order must remain pending until its queued job runs. Exactly one ProcessOrder dispatch proves that the retry does not duplicate asynchronous work.

  • Find the response assertions below $retryResponse in tests/Feature/OrderCreationTest.php.
  • Replace the existing assertions through the end of that test method with this code:
        $firstResponse
            ->assertCreated()
            ->assertJsonPath('created', true)
            ->assertJsonPath('data.status', 'pending');

        $retryResponse
            ->assertOk()
            ->assertJsonPath('created', false);

        $this->assertSame(
            $firstResponse['data']['id'],
            $retryResponse['data']['id'],
        );

        $this->assertDatabaseCount('orders', 1);
        Queue::assertPushed(ProcessOrder::class, 1);
    }

What do these assertions prove?

  • The first response proves a new order returns 201 with created set to true.
  • The retry proves an existing order returns 200 with created set to false.
  • The ID comparison proves both responses describe the same order.
  • The database assertion proves MySQL contains one matching row.
  • The queue assertion proves the first request dispatches one ProcessOrder job.
  • Save tests/Feature/OrderCreationTest.php.
  • Check the expanded idempotency contract by running:
sail test

What should I see?

The idempotency test should pass with every response, database, plus queue assertion satisfied. You have now pinned the retry contract to one order and one processing job.

Seeing an assertion failure?

Check that Queue::fake() runs before both requests. Compare the response paths with the nested data object returned by routes/api.php.

Help me debug the idempotency assertions.

Add the validation contract and run the suite

Invalid input must stop before CreateOrder can dispatch work. A dedicated test proves each required field reports a validation error while the queue stays empty.

  • Place your cursor below test_repeated_request_is_idempotent_and_dispatches_one_job() in tests/Feature/OrderCreationTest.php.
  • Add the validation test before the class's final closing brace:
    public function test_order_input_is_required(): void
    {
        Queue::fake();

        $this->postJson('/api/orders', [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors([
                'request_id',
                'customer_email',
                'total_cents',
            ]);

        Queue::assertNothingPushed();
    }

What does this test protect?

  • The empty JSON request exercises all three required input rules.
  • The response assertion proves invalid input returns 422.
  • The validation assertion checks for errors on request_id, customer_email, plus total_cents.
  • The final queue assertion proves rejected input dispatches no jobs.
  • Save tests/Feature/OrderCreationTest.php.

✔️ Awesome, I've got everything!

Your test file now protects the successful request path, the retry path, plus the validation path.

ⓧ I'd like to double check the full code

Compare your complete tests/Feature/OrderCreationTest.php file with this reference.

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Jobs\ProcessOrder;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use DatabaseMigrations;

    public function test_repeated_request_is_idempotent_and_dispatches_one_job(): void
    {
        Queue::fake();

        $payload = [
            'request_id' => 'checkout-2026-0001',
            'customer_email' => 'developer@example.com',
            'total_cents' => 2500,
        ];

        $firstResponse = $this->postJson('/api/orders', $payload);
        $retryResponse = $this->postJson('/api/orders', $payload);

        $firstResponse
            ->assertCreated()
            ->assertJsonPath('created', true)
            ->assertJsonPath('data.status', 'pending');

        $retryResponse
            ->assertOk()
            ->assertJsonPath('created', false);

        $this->assertSame(
            $firstResponse['data']['id'],
            $retryResponse['data']['id'],
        );

        $this->assertDatabaseCount('orders', 1);
        Queue::assertPushed(ProcessOrder::class, 1);
    }

    public function test_order_input_is_required(): void
    {
        Queue::fake();

        $this->postJson('/api/orders', [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors([
                'request_id',
                'customer_email',
                'total_cents',
            ]);

        Queue::assertNothingPushed();
    }
}

What does the complete file prove?

The first method protects idempotency plus single dispatch behavior. The second method protects validation plus an empty queue for rejected input.

Before you run the full suite, do you expect every HTTP, database, validation, plus queue assertion to pass together?

  • Run the complete test suite with this command:
sail test

What should I see?

You should see all order feature tests pass with no failures. That is the contract locked down. Your suite now protects the API across retries, transaction commits, plus the queue boundary.

Full suite still failing?

Compare tests/Feature/OrderCreationTest.php with the full-file reference. Check the failing assertion to identify whether the mismatch comes from the response, database count, validation errors, or queued job count.

Help me get the order feature suite passing.

Secret mission

Prevent Overlapping Processing

Duplicate intake is under control, but the same processing job can still reach two workers. Add an expiring overlap lock to serialize duplicate jobs for each order across multiple workers.

Clean Up Your Resources

Clean Up Your Resources

Your order API runs entirely through local Docker resources, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running Laravel Sail containers for the order API.
  • A MySQL named volume containing the orders table and its local data.
  • A Redis named volume containing queued job data and overlap locks.

Keep everything running

No action needed. Choose this if you are still actively testing retries or background processing.

  • Leave the Sail containers running to keep the API connected to MySQL and Redis.
  • Keep your Laravel source files available in Visual Studio Code for future reliability experiments.
  • Continue working locally without ongoing cloud costs.

Pause - I'll come back to this later

Shut down the running containers to free up local memory. Your named-volume data and Laravel source files stay available.

  • Stop the Sail containers while preserving their named-volume data by running this command:
sail stop

What does this command do?

The sail stop command stops the project containers without deleting their named volumes. Your MySQL and Redis state remains available for a later restart.

  • Confirm the terminal reports that the project containers stopped.

Delete - I don't want to use this again

Deleting the named volumes permanently removes all local MySQL and Redis data for this Sail project. This cleanup leaves your Laravel source files in place.

  • Remove the project containers and named volumes by running this command:
docker compose down -v

What gets deleted?

The -v option removes the named volumes together with the project containers. This clears the MySQL order data and Redis queue data stored in those volumes.

  • Confirm the terminal reports that the project containers and volumes were removed.

Nice Work!

Nice Work!

Outstanding work! Your Laravel order API now preserves one order across client retries while safely moving new work through an after-commit queue.

You've learned how to:

  • Build a JSON order endpoint with explicit validation. You also exposed the duplicate-write failure caused by processing the same request twice.
  • Enforce idempotency with a unique MySQL key. You wrapped order creation in a transaction with deadlock retries. New requests return 201. Repeated requests return 200 with the same order ID.
  • Dispatch Redis-backed processing only after commit. You also built feature tests that prove input validation. Those tests protect single-row creation and single-job dispatch.
  • Secret Mission: Add an expiring overlap lock that prevents separate workers from processing the same order simultaneously.

Ready to quiz yourself?