Dev Talks

Web Development Tutorials

Laravel REST API Part 9: Create Order API
Laravel

Laravel REST API Part 9: Create Order API

Published on Dec 13, 2024

In this installment of our Laravel REST API series, we’ll take our API development a step further by creating a POST API for managing orders. Previously, we built a product listing API with filters, tested it, refactored it, and added caching to it. Now, we’ll implement an example of an Order API to handle POST requests.


Plan for the Order API

Let’s define the requirements for the Order API. The API should handle requests and responses with the following structure:

Endpoint:

[POST] /api/orders

Request:

{
  "products": [
    {
      "id": 1,
      "quantity": 2
    },
    {
      "id": 2,
      "quantity": 1
    }
  ]
}

Response:

{
  "message": "Order success",
  "data": {
    "id": 123,
    "total_price": 200,
    "user": {
      "id": 1,
      "name": "John Doe",
      "email": "john@example.com"
    },
    "products": [
      {
        "id": 1,
        "name": "Product A",
        "price": 100,
        "quantity": 2
      },
      {
        "id": 2,
        "name": "Product B",
        "price": 50,
        "quantity": 1
      }
    ]
  }
}

That's our initial imagination of how it would be the API.

Implementation Steps

Step 1: Create Necessary Files

Run the following Artisan commands to generate the required files:

php artisan make:controller OrderController
php artisan make:request StoreOrderRequest
php artisan make:resource OrderResource

These commands create:

  • OrderController for handling the logic.
  • StoreOrderRequest for validating incoming requests.
  • OrderResource for structuring JSON responses.


Step 2: Define the Route

Add the following route to the routes/api.php file:

use App\Http\Controllers\OrderController;

Route::post('orders', [OrderController::class, 'store'])->name('orders.store');


Step 3: Add Validation Rules

In the StoreOrderRequest file, define validation rules for the API request:

return [
    'products' => 'required|array',
    'products.*.id' => 'required|exists:products,id',
    'products.*.quantity' => 'required|integer|min:1',
];


Step 4: Write the Controller Logic

Open OrderController and add the following logic for handling orders:

<?php
namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use App\Models\Order;
use App\Models\Product;
use DB;

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        $data = $request->validated();

        try {
            DB::beginTransaction();

            $orderPrice = 0;
            $products = [];

            foreach ($data['products'] as $productData) {
                $product = Product::find($productData['id']);

                $product->reduceStock($productData['quantity']);

                $orderPrice += $product->price * $productData['quantity'];

                $products[$product->id] = ['quantity' => $productData['quantity']];
            }

            $order = Order::create([
                'user_id' => auth()->user()->id,
                'total_price' => $orderPrice,
                'status' => Order::STATUS['Placed']
            ]);

            $order->products()->sync($products);

            DB::commit();

            $order->load(['user', 'products']);

            return response()->json([
                'message' => 'Order success',
                'data' => new OrderResource($order),
            ]);
        } catch (\Exception $ex) {
            DB::rollBack();

            return response()->json([
                'message' => 'Order failed',
                'error' => $ex->getMessage(),
            ]);
        }
    }
}

In the Product model, create a reduceStock method to manage stock:

public function reduceStock(int $quantity): void
{
    if ($this->stock < $quantity) {
        throw new \Exception('Out of stock for: ' . $this->name);
    }

    $this->stock -= $quantity;
    $this->save();
}

In this code:
- We apply validation first, if the code is not valid it will return validation errors.
- Then se use try & catch in case we have any step failier.
- We used DB transaction because the process of creating an order including updates in many tables, so we need to make sure all data is correct.
- We go through each product to calculate the total order price and check and reduce product stock
- Then we created the order and synced the products in the pivot table
- And finally we lazy eager loaded the necessary relations that we will use in the Resource.


The resource file could be something like this:

<?php

namespace App\Http\Resources;

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            "id" => $this->id,
            "total_price" => $this->total_price,
            "status" => Order::STATUS[$this->status],
            "user" => new UserResource($this->whenLoaded("user")),
            "products" => ProductResource::collection($this->whenLoaded("products")),
        ];
    }
}


Step 5: Refactor Using an Action Class

As mentioned in the introduction we introduced actions with products, so we will use the same pattern with orders as well.
So, let's extract some logic from the controller and Create Order/CreateOrderAction.php inside Actions folder, and we could have the following logic:

<?php
namespace App\Http\Actions\Order;

use App\Models\Order;
use App\Models\Product;

class CreateOrderAction
{
    public function execute(array $data): Order
    {
        $orderPrice = 0;
        $products = [];

        foreach ($data['products'] as $productData) {
            $product = Product::find($productData['id']);

            $product->reduceStock($productData['quantity']);

            $orderPrice += $product->price * $productData['quantity'];

            $products[$product->id] = ['quantity' => $productData['quantity']];
        }

        $order = Order::create([
            'user_id' => auth()->user()->id,
            'total_price' => $orderPrice,
            'status' => Order::STATUS['Placed']
        ]);

        $order->products()->sync($products);

        return $order;
    }
}


Update the Controller

Modify the store method in OrderController to use the CreateOrderAction class:

public function store(StoreOrderRequest $request, CreateOrderAction $createOrderAction)
{
    $data = $request->validated();

    try {
        DB::beginTransaction();

        $order = $createOrderAction->execute($data);

        DB::commit();

        $order->load(['user', 'products']);

        return response()->json([
            'message' => 'Order success',
            'data' => new OrderResource($order),
        ]);
    } catch (\Exception $ex) {
        DB::rollBack();

        return response()->json([
            'message' => 'Order failed',
            'error' => $ex->getMessage(),
        ]);
    }
}


Testing the Order API

I want to include also testing example of a POST API and I don't want to make this series very long, so I'll just add the logic here:

Step 1: Create a Test Class

Run the following command to generate a test class:

php artisan make:test OrderControllerTest

If you have a lot of logic inside OrderController you may make a separate file for each API, but for now we are good!

Step 2: Set Up the Test

Use the RefreshDatabase trait and mock authentication:

public function setUp(): void
{
    parent::setUp();
    $this->actingAs(User::factory()->create());
}


Step 3: Validate Inputs

Let's start our POST API with testing validations and inputs, we might have something similar to that:

public function test_it_requires_products_array()
{
    $response = $this->postJson('/api/orders', []);

    $response->assertStatus(422)->assertJsonValidationErrors(['products']);
}

public function test_it_requires_valid_product_id_and_quantity()
{
    $response = $this->postJson('/api/orders', [
        'products' => [
            ['id' => 999, 'quantity' => 1],
            ['id' => 1, 'quantity' => 0],
        ]
    ]);

    $response->assertStatus(422);
    $response->assertJsonValidationErrors(['products.0.id', 'products.1.quantity']);
}

Those are two basic test cases to make sure that there is a validation error returns when user doesn't send a products array or send non exist products

Step 4: Test Order Creation (The happy path)

public function test_it_creates_order_successfully_with_valid_data()
{
    $product = Product::factory()->create(['price' => 100, 'stock' => 10]);

    $response = $this->postJson('/api/orders', [
        'products' => [
            ['id' => $product->id, 'quantity' => 2]
        ]
    ]);

    $response->assertStatus(200);
    $this->assertDatabaseHas('orders', [
        'user_id' => auth()->id(),
        'total_price' => 200,
    ]);
}

This test case creating a product and trying to make an order with that product, and we have three assertions
First one: we make sure that we receive success status and we have the exact Json response as we excpected.
Second one: we make sure that the order is inserted in the database with correct data
Third one: we do a double check on the product quantity as it should be reduced


Step 5: Test Stock Exception

In Product model in reduceStock function we fired an exception in case the user sends quantity bigger than we have in the stock. So, we may want to test that as well:

public function test_it_does_not_create_order_on_exception()
{
    $product = Product::factory()->create(['stock' => 10]);

    $response = $this->postJson('/api/orders', [
        'products' => [
            ['id' => $product->id, 'quantity' => 11]
        ]
    ]);

    $response->assertStatus(200)->assertJson(['message' => 'Order failed']);
}

Here we make sure that the Order didn't created and the product stock still the same

Conclusion

In this article, we explored the creation of a POST API for orders in Laravel, including validation, logic implementation, refactoring, and testing. This approach ensures a robust and scalable API for handling orders and similar POST requests. In the real world you might have more logic like sending a notifications, and payment of-course, we only implemented the basic stuff and you may take it further from here.