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

Cart API, Service and Tests

This time we will implement a service class for the cart.

Ideally, we want to repeat code as less as possible and employ the SOLID design principle. If you're unfamiliar with that principle, we have SOLID Code in Laravel course to cover this topic in depth.


API Controller

First, let's implement an API Controller for a cart without a service.

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

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

namespace App\Http\Controllers\Api\V1\Customer;
 
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
 
class CartController extends Controller
{
public function index(): JsonResponse
{
$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
 
return response()->json($cart);
}
 
public function add(Product $product): JsonResponse
{
$this->authorize('cart.add');
 
$restaurant = $product->category->restaurant;
 
$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
 
$validator = Validator::make($cart, [
'items' => ['array'],
'items.*.restaurant_id' => ['required', 'in:' . $restaurant->id],
]);
 
if ($validator->fails()) {
return response()->json(['message' => 'Can\'t add product from different vendor.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
 
$item = $product->toArray();
$item['uuid'] = (string) str()->uuid();
$item['restaurant_id'] = $restaurant->id;
 
session()->push('cart.items', $item);
session()->put('cart.restaurant_name', $restaurant->name);
session()->put('cart.restaurant_id', $restaurant->id);
 
$this->updateTotal();
 
return response()->json(session('cart'), Response::HTTP_ACCEPTED);
}
 
public function remove($uuid): JsonResponse
{
$items = collect(session('cart.items'))
->reject(function ($item) use ($uuid) {
return $item['uuid'] == $uuid;
});
 
session(['cart.items' => $items->values()->toArray()]);
 
$this->updateTotal();
 
return response()->json(session('cart'), Response::HTTP_ACCEPTED);
}
 
public function destroy(): Response
{
session()->forget('cart');
 
return response()->noContent();
}
 
protected function updateTotal(): void
{
$items = collect(session('cart.items'));
 
session()->put('cart.total', $items->sum('price'));
}
}

Here we can immediately identify a few potential places where code can be hard to maintain in the future. We've duplicate calls to retrieve cart data with defaults.

$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);

The updateTotal() is duplicated in web controller for the cart. Code is also hard to read and has session and array keys repeated in multiple places several times. We will fix this in the next section of this lesson.

Now add api routes for the customer user. Create the new customer.php file.

routes/api/v1/customer.php

use App\Http\Controllers\Api\V1\Customer\CartController;
use Illuminate\Support\Facades\Route;
 
Route::group([
'prefix' => 'customer',
'as' => 'customer.',
'middleware' => ['auth'],
], function () {
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');
});

And include it in the api.php file.

routes/api.php

// ...
 
include __DIR__ . '/api/v1/admin.php';
include __DIR__ . '/api/v1/vendor.php';
include __DIR__ . '/api/v1/customer.php';

Service

Let's think a bit about what CartService should look like. Working with the session data related to the cart within the service might be a good idea.

Requirements for the CartService to wrap the logic could be as follows:

Method Operation
all() Retrieve all cart data
items() Retrieve items in cart
flush() Flush data in the cart
addItem() Add item into cart
removeItem() Remove the item from the cart and let us know if any items were removed
updateTotal() Automatically recalculate total price when adding/removing an item
total() Retrieve total sum of the cart
restaurantId() Get the ID of the restaurant items in the cart belongs to

Create CartService with mentioned methods.

app/Services/CartService.php

namespace App\Services;
 
use App\Models\Product;
 
class CartService
{
public function all(): array
{
return session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
}
 
public function items(): array
{
return $this->all()['items'];
}
 
public function total(): int
{
return (int) $this->all()['total'];
}
 
public function restaurantId(): int
{
return (int) $this->all()['restaurant_id'];
}
 
public function flush(): void
{
session()->forget('cart');
}
 
public function addItem(Product $product): void
{
$restaurant = $product->category->restaurant;
 
$item = $product->toArray();
$item['uuid'] = (string) str()->uuid();
$item['restaurant_id'] = $restaurant->id;
 
session()->push('cart.items', $item);
session()->put('cart.restaurant_name', $restaurant->name);
session()->put('cart.restaurant_id', $restaurant->id);
 
$this->updateTotal();
}
 
public function removeItem(string $uuid = ''): bool
{
$items = collect($this->items());
 
[$removed, $new] = $items->partition(fn ($item) => $item['uuid'] === $uuid);
 
if (! count($removed)) {
return false;
}
 
session(['cart.items' => $new->values()->toArray()]);
 
$this->updateTotal();
 
return true;
}
 
protected function updateTotal(): void
{
session()->put('cart.total', collect($this->items())->sum('price'));
}
}

The all() method is now a single source of truth, and we do not have to initialize cart data with default values.

If we look at the removeItem() method, we can see the partition call:

[$removed, $new] = $items->partition(fn ($item) => $item['uuid'] === $uuid);

The partition Collection method separates elements that pass a given truth test and can be combined with PHP array destructuring.

Update API Controller

Inject CartService into /Api/V1/Customer/CartController. This time it is called $cart and not $cartService because cart will always mean one thing. We do not have a cart model or other similar references.

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

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
 
class CartController extends Controller
{
public function __construct(public CartService $cart)
{
}
 
// ...

And update the methods to consume CartService.

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

public function index(): JsonResponse
{
$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
 
return response()->json($cart);
return response()->json($this->cart->all());
}
 
public function add(Product $product): JsonResponse
{
$this->authorize('cart.add');
 
$restaurant = $product->category->restaurant;
 
$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
 
$validator = Validator::make($cart, [
$validator = Validator::make($this->cart->all(), [
'items' => ['array'],
'items.*.restaurant_id' => ['required', 'in:' . $restaurant->id],
'items.*.restaurant_id' => [
'required',
'in:' . $product->category->restaurant->id,
],
]);
 
if ($validator->fails()) {
return response()->json(['message' => 'Can\'t add product from different vendor.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
 
$item = $product->toArray();
$item['uuid'] = (string) str()->uuid();
$item['restaurant_id'] = $restaurant->id;
$this->cart->addItem($product);
 
session()->push('cart.items', $item);
session()->put('cart.restaurant_name', $restaurant->name);
session()->put('cart.restaurant_id', $restaurant->id);
 
$this->updateTotal();
 
return response()->json(session('cart'), Response::HTTP_ACCEPTED);
return response()->json($this->cart->all(), Response::HTTP_ACCEPTED);
}
 
public function remove($uuid): JsonResponse
{
$items = collect(session('cart.items'))
->reject(function ($item) use ($uuid) {
return $item['uuid'] == $uuid;
});
 
session(['cart.items' => $items->values()->toArray()]);
 
$this->updateTotal();
abort_if(! $this->cart->removeItem($uuid), Response::HTTP_NOT_FOUND);
 
return response()->json(session('cart'), Response::HTTP_ACCEPTED);
return response()->json($this->cart->all(), Response::HTTP_ACCEPTED);
}
 
public function destroy(): Response
{
session()->forget('cart');
$this->cart->flush();
 
return response()->noContent();
}
 
protected function updateTotal(): void
{
$items = collect(session('cart.items'));
 
session()->put('cart.total', $items->sum('price'));
}

You can immediately see how much "thinner" our controller is. Code is now less susceptible to errors, and we can track the bugs faster if such happens.

Update Web Controller

Now apply that practice to the web controller.

app/Http/Controllers/Customer/CartController.php

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Validator;
use Inertia\Inertia;
use Inertia\Response;
 
class CartController extends Controller
{
public function __construct(public CartService $cart)
{
}
 
// ...

app/Http/Controllers/Customer/CartController.php

public function add(Product $product): RedirectResponse
{
$this->authorize('cart.add');
 
$restaurant = $product->category->restaurant;
 
$cart = session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]);
 
$validator = Validator::make($cart, [
$validator = Validator::make($this->cart->all(), [
'items' => ['array'],
'items.*.restaurant_id' => ['required', 'in:' . $restaurant->id],
'items.*.restaurant_id' => [
'required',
'in:' . $product->category->restaurant->id,
],
]);
 
if ($validator->fails()) {
return back()->withErrors(['message' => 'Can\'t add product from different vendor.']);
}
 
$item = $product->toArray();
$item['uuid'] = (string) str()->uuid();
$item['restaurant_id'] = $restaurant->id;
 
session()->push('cart.items', $item);
session()->put('cart.restaurant_name', $restaurant->name);
session()->put('cart.restaurant_id', $restaurant->id);
 
$this->updateTotal();
$this->cart->addItem($product);
 
return back();
}
 
public function remove(string $uuid)
{
$items = collect(session('cart.items'))
->reject(function ($item) use ($uuid) {
return $item['uuid'] == $uuid;
});
 
session(['cart.items' => $items->values()->toArray()]);
 
$this->updateTotal();
abort_if(! $this->cart->removeItem($uuid), HttpResponse::HTTP_NOT_FOUND);
 
return back();
}
 
public function destroy()
{
session()->forget('cart');
$this->cart->flush();
 
return back();
}
 
protected function updateTotal(): void
{
$items = collect(session('cart.items'));
 
session()->put('cart.total', $items->sum('price'));
}

Let's not forget to consume CartService in other places where we used session helper for cart. Let the service class handle everything in one place.

app/Http/Middleware/HandleInertiaRequests.php

use App\Services\CartService;
 
// ...
 
'cart' => session('cart', [
'items' => [],
'total' => 0,
'restaurant_name' => '',
'restaurant_id' => '',
]),
'cart' => (new CartService())->all(),

app/Http/Controllers/Customer/OrderController.php

use App\Services\CartService;
 
class OrderController extends Controller
{
public function __construct(public CartService $cart)
{
}
 
// ...
 
public function store(StoreOrderRequest $request): RedirectResponse
{
// ...
 
session()->forget('cart');
$this->cart->flush();
 
// ..

app/Http/Requests/Customer/StoreOrderRequest.php

namespace App\Http\Requests\Customer;
 
use App\Services\CartService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
 
// ...
 
protected function prepareForValidation(): void
{
$cart = session('cart');
$cart = new CartService();
 
$this->merge([
'restaurant_id' => $cart['restaurant_id'],
'items' => $cart['items'],
'total' => $cart['total'],
'restaurant_id' => $cart->restaurantId(),
'items' => $cart->items(),
'total' => $cart->total(),
]);
}

Tests

Finally, let's cover that with tests.

php artisan make:test Api/CartTest

tests/Feature/Api/CartTest.php

namespace Tests\Feature\Api;
 
use App\Models\Product;
use App\Models\User;
use App\Services\CartService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class CartTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_customer_can_view_cart(): void
{
$customer = User::factory()->customer()->create();
 
$response = $this
->actingAs($customer)
->get(route('api.customer.cart.index'));
 
$response->assertOk();
}
 
public function test_customer_can_add_item_to_cart()
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$response = $this
->actingAs($customer)
->postJson(route('api.customer.cart.add', $product));
 
$response->assertAccepted();
}
 
public function test_customer_can_remove_item_from_cart()
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->postJson(route('api.customer.cart.add', $product));
 
$firstInCart = (new CartService)->items()[0]['uuid'];
$response = $this
->actingAs($customer)
->postJson(route('api.customer.cart.remove', $firstInCart));
 
$response->assertAccepted();
}
 
public function test_customer_cant_remove_non_existing_item_from_cart()
{
$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.cart.remove', '74cf4ca6-7140-4ba7-909d-f7a7d1c4c7b'));
 
$response->assertNotFound();
}
}
php artisan make:test Web/CartTest

tests/Feature/Web/CartTest.php

namespace Tests\Feature\Web;
 
use App\Models\Product;
use App\Models\User;
use App\Services\CartService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class CartTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_customer_can_view_cart()
{
$customer = User::factory()->customer()->create();
 
$response = $this
->actingAs($customer)
->get(route('customer.cart.index'));
 
$response->assertInertia(function (Assert $page) {
return $page->component('Customer/Cart');
});
}
 
public function test_customer_can_add_item_to_cart(): void
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$response = $this
->actingAs($customer)
->post(route('customer.cart.add', $product));
 
$response->assertRedirect()->assertSessionDoesntHaveErrors();
}
 
public function test_customer_can_remove_item_from_cart(): void
{
$customer = User::factory()->customer()->create();
$product = Product::first();
 
$this->actingAs($customer)->post(route('customer.cart.add', $product));
 
$firstInCart = (new CartService)->items()[0]['uuid'];
$response = $this
->actingAs($customer)
->post(route('customer.cart.remove', $firstInCart));
 
$response->assertRedirect()->assertSessionDoesntHaveErrors();
}
 
public function test_customer_cant_remove_non_existing_item_from_cart(): 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.cart.remove', '74cf4ca6-7140-4ba7-909d-f7a7d1c4c7b'));
 
$response->assertNotFound();
}
}

And run the tests.

php artisan test --filter CartTest
 
PASS Tests\Feature\Api\CartTest
customer can view cart 1.09s
customer can add item to cart 0.28s
customer can remove item from cart 0.21s
customer cant remove non existing item from cart 0.22s
 
PASS Tests\Feature\Web\CartTest
customer can view cart 0.30s
customer can add item to cart 0.22s
customer can remove item from cart 0.23s
customer cant remove non existing item from cart 0.24s
 
Tests: 8 passed (16 assertions)
Duration: 2.88s