Back to Course |
Laravel Web to Mobile API: Reuse Old Code with Services

Customer Orders API, Service and Tests

In this lesson, we will continue with API services. The goals are:

  • Implement API endpoints to place an order and list orders
  • Implement OrderService to use in controllers
  • Write tests for both API and Web endpoints

API Controller

Create API resources for Order Model.

php artisan make:resource Api/V1/Customer/OrderResource
php artisan make:resource Api/V1/Customer/OrderCollection

Create a new OrderController.

php artisan make:controller Api/V1/Customer/OrderController

The logic is the same as in Web Controller.

app/Http/Controllers/Api/V1/Customer/OrderController.php

namespace App\Http\Controllers\Api\V1\Customer;
 
use App\Enums\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\StoreOrderRequest;
use App\Http\Resources\Api\V1\Customer\OrderCollection;
use App\Http\Resources\Api\V1\Customer\OrderResource;
use App\Models\Order;
use App\Notifications\NewOrderCreated;
use App\Services\CartService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
 
class OrderController extends Controller
{
public function __construct(public CartService $cart)
{
}
 
public function index(): OrderCollection
{
$this->authorize('order.viewAny');
 
$orders = Order::with(['restaurant', 'products'])
->where('customer_id', auth()->id())
->latest()
->get();
 
return new OrderCollection($orders);
}
 
public function store(StoreOrderRequest $request): OrderResource
{
$user = $request->user();
$attributes = $request->validated();
 
$order = DB::transaction(function () use ($user, $attributes) {
$order = $user->orders()->create([
'restaurant_id' => $attributes['restaurant_id'],
'total' => $attributes['total'],
'status' => OrderStatus::PENDING,
]);
 
$order->products()->createMany($attributes['items']);
 
return $order;
});
 
$order->restaurant->owner->notify(new NewOrderCreated($order));
 
$this->cart->flush();
 
return new OrderResource($order);
}
}

Then add a new apiResource() route to the customer.php file.

routes/api/v1/customer.php

use App\Http\Controllers\Api\V1\Customer\CartController;
use App\Http\Controllers\Api\V1\Customer\OrderController;
use Illuminate\Support\Facades\Route;
 
// ...
 
Route::get('cart', [CartController::class, 'index'])->name('cart.index');
Route::post('cart/{product}/add', [CartController::class, 'add'])->name('cart.add');
Route::post('cart/{uuid}/remove', [CartController::class, 'remove'])->name('cart.remove');
Route::delete('cart', [CartController::class, 'destroy'])->name('cart.destroy');
 
Route::apiResource('orders', OrderController::class);
 
// ...

Service

The OrderService primarily consists of the getCustomerOrders and placeOrder methods.

Create a new file.

app/Services/OrderService.php

namespace App\Services;
 
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\User;
use App\Notifications\NewOrderCreated;
use App\Services\CartService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
 
class OrderService
{
public function __construct(
public CartService $cart
) {
}
 
public function getCustomerOrders(): Collection
{
return Order::with(['restaurant', 'products'])
->where('customer_id', auth()->id())
->latest()
->get();
}
 
public function placeOrder(User $user, array $attributes): Order
{
$order = DB::transaction(function () use ($user, $attributes) {
$order = $user->orders()->create([
'restaurant_id' => $attributes['restaurant_id'],
'total' => $attributes['total'],
'status' => OrderStatus::PENDING,
]);
 
$order->products()->createMany($attributes['items']);
 
return $order;
});
 
$order->restaurant->owner->notify(new NewOrderCreated($order));
 
$this->cart->flush();
 
return $order;
}
}

Update API Controller

Now let's inject OrderService instead of CartService in the controller. OrderService flushes the cart after placing an order, so we no longer need CartService there.

app/Http/Controllers/Api/V1/Customer/OrderController.php

namespace App\Http\Controllers\Api\V1\Customer;
 
use App\Enums\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\StoreOrderRequest;
use App\Http\Resources\Api\V1\Customer\OrderCollection;
use App\Http\Resources\Api\V1\Customer\OrderResource;
use App\Models\Order;
use App\Notifications\NewOrderCreated;
use App\Services\CartService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Services\OrderService;
 
class OrderController extends Controller
{
public function __construct(public CartService $cart)
public function __construct(public OrderService $orderService)
{
}
 
public function index(): OrderCollection
{
$this->authorize('order.viewAny');
 
$orders = Order::with(['restaurant', 'products'])
->where('customer_id', auth()->id())
->latest()
->get();
 
return new OrderCollection($orders);
return new OrderCollection(
$this->orderService->getCustomerOrders()
);
}
 
public function store(StoreOrderRequest $request): OrderResource
{
$user = $request->user();
$attributes = $request->validated();
 
$order = DB::transaction(function () use ($user, $attributes) {
$order = $user->orders()->create([
'restaurant_id' => $attributes['restaurant_id'],
'total' => $attributes['total'],
'status' => OrderStatus::PENDING,
]);
 
$order->products()->createMany($attributes['items']);
 
return $order;
});
 
$order->restaurant->owner->notify(new NewOrderCreated($order));
 
$this->cart->flush();
$order = $this->orderService->placeOrder(
$request->user(),
$request->validated()
);
 
return new OrderResource($order);
}
}

Update Web Controller

Now we can update another web controller in the same fashion.

app/Http/Controllers/Customer/OrderController.php

namespace App\Http\Controllers\Customer;
 
use App\Enums\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\StoreOrderRequest;
use App\Models\Order;
use App\Notifications\NewOrderCreated;
use App\Services\CartService;
use App\Services\OrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
 
class OrderController extends Controller
{
public function __construct(public CartService $cart)
public function __construct(public OrderService $orderService)
{
}
 
public function index(): Response
{
$this->authorize('order.viewAny');
 
$orders = Order::with(['restaurant', 'products'])
->where('customer_id', auth()->id())
->latest()
->get();
 
return Inertia::render('Customer/Orders', [
'orders' => $orders,
'orders' => $this->orderService->getCustomerOrders(),
]);
}
 
public function store(StoreOrderRequest $request): RedirectResponse
{
$user = $request->user();
$attributes = $request->validated();
 
$order = DB::transaction(function () use ($user, $attributes) {
$order = $user->orders()->create([
'restaurant_id' => $attributes['restaurant_id'],
'total' => $attributes['total'],
'status' => OrderStatus::PENDING,
]);
 
$order->products()->createMany($attributes['items']);
 
return $order;
});
 
$order->restaurant->owner->notify(new NewOrderCreated($order));
 
$this->cart->flush();
$this->orderService->placeOrder(
$request->user(),
$request->validated()
);
 
return to_route('customer.orders.index')
->withStatus('Order accepted.');
}
}

Tests

Create a new OrderTest class for API endpoints.

php artisan make:test Api/OrderTest

tests/Feature/Api/OrderTest.php

namespace Tests\Feature\Api;
 
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class OrderTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_customer_can_place_order(): void
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->postJson(route('api.customer.cart.add', $product));
 
$response = $this->actingAs($customer)->postJson(route('api.customer.orders.store'));
 
$response->assertCreated();
}
 
public function test_customer_cant_place_empty_order()
{
$customer = User::factory()->customer()->create();
 
$response = $this->actingAs($customer)->postJson(route('api.customer.orders.store'));
 
$response->assertUnprocessable();
}
 
public function test_customer_can_see_orders()
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->postJson(route('api.customer.cart.add', $product));
$this->actingAs($customer)->postJson(route('api.customer.orders.store'));
 
$response = $this
->actingAs($customer)
->get(route('customer.orders.index'));
 
$response->assertOk();
}
}

Create a new OrderTest class for Web endpoints.

php artisan make:test Web/OrderTest

tests/Feature/Web/OrderTest.php

namespace Tests\Feature\Web;
 
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class OrderTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_customer_can_place_order(): void
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->post(route('customer.cart.add', $product));
 
$response = $this->actingAs($customer)->post(route('customer.orders.store'));
 
$response->assertRedirectToRoute('customer.orders.index');
}
 
public function test_customer_cant_place_empty_order(): void
{
$customer = User::factory()->customer()->create();
 
$response = $this->actingAs($customer)->post(route('customer.orders.store'));
 
$response->assertSessionHasErrors(['restaurant_id', 'items', 'total']);
}
 
public function test_customer_can_see_orders(): void
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->post(route('customer.cart.add', $product));
$this->actingAs($customer)->post(route('customer.orders.store'));
 
$response = $this
->actingAs($customer)
->get(route('customer.orders.index'));
 
$response->assertInertia(function (Assert $page) {
return $page->component('Customer/Orders')
->has('orders');
});
}
}

And run the tests to validate that everything is working as expected.

php artisan test --filter OrderTest
 
PASS Tests\Feature\Api\OrderTest
customer can place order 1.18s
customer cant place empty order 0.22s
customer can see orders 0.26s
 
PASS Tests\Feature\Web\OrderTest
customer can place order 0.26s
customer cant place empty order 0.22s
customer can see orders 0.29s
 
Tests: 6 passed (17 assertions)
Duration: 2.52s