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.
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';
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.
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.
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(), ]);}
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