In this lesson, we will add functionality for Customers to add products to carts. Customers would see the total order sum in the top bar.
Users can review cart contents and remove items from the cart page when pressing the View basket button.
Customers will be able to order products from the same restaurant. In case the customer tries to add a product from another restaurant, the following dialog will appear:
If the customer confirms the action, the application will clear the cart, and a new product from another restaurant will be added to the cart.
First, let's add a new cart.add
permission to show/hide Add and View basket buttons for non-customer users.
Let's add cart.add
permission to PermissionSeeder
this time.
database/seeders/PermissionSeeder.php
collect($resources) ->crossJoin($actions) ->map(function ($set) { return implode('.', $set); })->each(function ($permission) { Permission::create(['name' => $permission]); }); Permission::create(['name' => 'cart.add']);
Yet we still do not have a customer
role in the database. Let's also create this one and assign cart.add
permission to the customer
role in RoleSeeder
.
database/seeders/RoleSeeder.php
public function run(): void{ $this->createAdminRole(); $this->createVendorRole(); $this->createCustomerRole(); } protected function createCustomerRole(): void { $permissions = Permission::where('name', 'cart.add')->get(); $this->createRole(RoleName::CUSTOMER, $permissions); }
To avoid creating a customer user manually, let's make it in UserSeeder
for testing purposes.
database/seeders/UserSeeder.php
public function run(): void{ $this->createAdminUser(); $this->createVendorUser(); $this->createCustomerUser(); } public function createCustomerUser() { $vendor = User::create([ 'name' => 'Loyal Customer', 'email' => 'customer@admin.com', 'password' => bcrypt('password'), ]); $vendor->roles()->sync(Role::where('name', RoleName::CUSTOMER->value)->first()); }
Newly registered users should also get a customer
role assigned automatically after account creation. We can implement this by updating RegisteredUserController
.
app/Http/Controllers/Auth/RegisteredUserController.php
use App\Enums\RoleName; use App\Models\Role; use Illuminate\Support\Facades\DB; // ... public function store(Request $request): RedirectResponse{ // ... $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $user = DB::transaction(function () use ($request) { $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $user->roles()->sync(Role::where('name', RoleName::CUSTOMER->value)->first()); return $user; }); // ...}
Now that we have the customer
role set up, we can continue working on "Add to cart" button on the public restaurant page.
Create a new CartController
for the customer.
php artisan make:controller Customer/CartController
And implement add()
, destroy()
and updateTotal()
methods in Controller.
app/Http/Controllers/Customer/CartController.php
namespace App\Http\Controllers\Customer; use App\Http\Controllers\Controller;use App\Models\Product;use Illuminate\Http\RedirectResponse;use Illuminate\Support\Facades\Validator; class CartController extends Controller{ 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, [ 'items' => ['array'], 'items.*.restaurant_id' => ['required', 'in:' . $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(); return back(); } public function destroy() { session()->forget('cart'); return back(); } protected function updateTotal(): void { $items = collect(session('cart.items')); session()->put('cart.total', $items->sum('price')); }}
Let's talk about the add()
method first. Since all our requests are stateful, we can store all cart information in the session. session()
helper returns data stored by the key cart
. The second argument returns default values if a key's yet to be defined.
$cart = session('cart', [ 'items' => [], 'total' => 0, 'restaurant_name' => '', 'restaurant_id' => '',]);
items
will store an array of products in the cart.total
is the sum of all product prices so that we can show the total sum for the user.restaurant_name
will store the restaurant name we are assembling orders for. It will be displayed in case the user wants to remove the order.restaurant_id
stores the restaurant id for the current order. It will help us later when we create an order. It is more convenient than fetching the restaurant id from the first product in the cart.Then we create a custom validator to check if all products in the cart belong to the same restaurant.
$validator = Validator::make($cart, [ 'items' => ['array'], 'items.*.restaurant_id' => ['required', 'in:' . $restaurant->id],]);
If the items
array is empty, the rule will pass. As you can see, it is possible to validate any data you want using the Validator
facade, not only Request data.
We cast the product to an array and add uuid
with restaurant_id
values to the item.
$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);
We will need the uuid
value to remove the item from the cart later. But why not item id
? Item id
is no longer unique in our cart.items
array because we can have multiple products with the same id
. Although it may seem irrelevant, how does it matter which item with the same id
gets removed? Well, it matters when rendering items in the cart.
Let's see this example with IDs:
[1, 1, 2, 1, 1, 3, 2]
If the user presses on the last element with id 2, the item somewhere from the middle would be removed, and UI will confuse customers.
[1, 1, 1, 1, 3, 2]
But you might ask if we could remove items by the index (index: 6
). You would be correct, but then we would have a concurrency problem. Requests might be delayed and executed not in the order user issued them, and by the time index 6
might not exist anymore, resulting in errors or removing the item user didn't click on. So this is even worse.
Why not increase item quantity, then? It is a possible solution, but if you add some extras (like different sauces) to one item, this might be an issue.
To have unique identities on cart items using UUIDs is the way to go in this case.
From UUIDs, back to saving the data.
session()->push('cart.items', ...)
method appends the cart.items
array.
session()->put(...)
sets the value for the given key.
In the destroy()
method, we clear whole cart data using session()->forget('cart')
.
The updateTotal()
method is a helper method to recalculate the sum of product prices. It will be called more times in the future, for example, when we remove an item, so it is a good idea to have it separately.
Create a new customer.php
file where we will store routes to the cart methods.
routes/customer.php
use App\Http\Controllers\Customer\CartController;use Illuminate\Support\Facades\Route; Route::group([ 'prefix' => 'customer', 'as' => 'customer.', 'middleware' => ['auth'],], function () { Route::post('cart/{product}/add', [CartController::class, 'add'])->name('cart.add'); Route::delete('cart', [CartController::class, 'destroy'])->name('cart.destroy');});
And include it in the main web.php
routes file.
routes/web.php
require __DIR__ . '/auth.php';require __DIR__ . '/admin.php';require __DIR__ . '/vendor.php';require __DIR__ . '/customer.php';
It is time to update the Restaurant.vue
page and add the addProduct
call when the user presses on Add button.
resources/js/Pages/Restaurant.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'import { Head } from '@inertiajs/vue3' import { Head, usePage, useForm } from '@inertiajs/vue3' const page = usePage() const form = useForm({}) defineProps({ restaurant: { type: Object }}) const addProduct = (product) => { form.post(route('customer.cart.add', product), { preserveScroll: true, onError: () => { if (confirm(`This will remove your ${page.props.cart.restaurant_name} order.`)) { form.delete(route('customer.cart.destroy'), { onSuccess: () => addProduct(product) }) } } })}</script> <template><!-- ... --> <div class="grow flex items-end"> <button class="btn btn-primary btn-sm" type="button"> Add {{ (product.price / 100).toFixed(2) }} € (Coming soon) <button v-if="can('cart.add') || !$page.props.auth.user" @click="addProduct(product)" class="btn btn-primary btn-sm" type="button" > Add {{ (product.price / 100).toFixed(2) }} € </button> </div><!-- ... --></template>
When a customer adds a product, customer.cart.route
is called. If the validation fails, it means the user tried to add a product from another restaurant. Then we offer the customer to clear the current order, and if agreed, the customer.cart.destroy
method gets called and calls the addProduct
function again.
form.post(route('customer.cart.add', product), { preserveScroll: true, onError: () => { if (confirm(`This will remove your ${page.props.cart.restaurant_name} order.`)) { form.delete(route('customer.cart.destroy'), { onSuccess: () => addProduct(product) }) } }
We have the following Vue condition on the Add button to display it only for users with cart.add
permission or non-authenticated users. If a guest user presses the button, Inertia will redirect it to the login page.
v-if="can('cart.add') || !$page.props.auth.user"
Optionally you can add a btn-sm
class to make these buttons smaller.
resources/css/app.css
.btn-sm { @apply px-2 py-1 text-sm;}
Let's create a View basket button in the top bar to display the total sum of the cart.
To have cart data from the session in the app, update HandleInertiaRequests
Middleware like this:
app/Http/Middleware/HandleInertiaRequests.php
return array_merge(parent::share($request), [ // ... 'status' => session('status'), 'cart' => session('cart', [ 'items' => [], 'total' => 0, 'restaurant_name' => '', 'restaurant_id' => '', ]),]);
This way, we can have cart data present at all times and updated after every successful request.
And insert the <Link>
into the AuthenticatedLayoute.vue
file. Now href attribute follows nowhere href="#"
. We will update it in the next step.
resources/js/Layouts/AuthenticatedLayout.vue
<template><!-- ... --> <div v-if="$page.props.auth.user" class="hidden sm:flex sm:items-center sm:ml-6"> <Link v-if="can('cart.add')" href="#" class="btn btn-primary" > View basket {{ ($page.props.cart.total / 100).toFixed(2) }} € </Link> <!-- Settings Dropdown --> <div class="ml-3 relative"> <Dropdown align="right" width="48"><!-- ... --></template>
When you press Add, you should see the total sum at the top updated automatically.
Time to finish the last functionality for the cart. Add index()
and remove()
methods to CartController
.
app/Http/Controllers/Customer/CartController.php
use Inertia\Inertia;use Inertia\Response; // ... public function index(): Response{ return Inertia::render('Customer/Cart');} // ... 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(); return back();}
Here we use the reject()
collection method to remove the item containing the specified uuid
. Then we update the cart.items
key with the new array and recalculate the cart total sum.
Add new routes to index()
and destroy()
methods:
routes/customer.php
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 update the href
attribute for the View basket button to navigate to the cart page.
resources/js/Layouts/AuthenticatedLayout.vue
<template><!-- ... --> <div v-if="$page.props.auth.user" class="hidden sm:flex sm:items-center sm:ml-6"> <Link v-if="can('cart.add')" :href="route('customer.cart.index')" class="btn btn-primary" > View basket {{ ($page.props.cart.total / 100).toFixed(2) }} € </Link> <!-- Settings Dropdown --> <div class="ml-3 relative"> <Dropdown align="right" width="48"><!-- ... --></template>
Create a new Cart.vue
page.
resources/js/Pages/Customer/Cart.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'import { Head, useForm } from '@inertiajs/vue3'import PrimaryButton from '@/Components/PrimaryButton.vue' const form = useForm({ product: {}}) const removeProduct = (uuid) => { form.post(route('customer.cart.remove', uuid), { preserveScroll: true })}</script> <template> <Head title="Cart" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Cart</h2> </template> <div class="py-12"> <div class="max-w-2xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900 overflow-x-scroll flex flex-col gap-6"> <div v-for="product in $page.props.cart.items" :key="product.uuid" class="border-b pb-6" > <div class="flex gap-4"> <div class="flex-none w-14"> <img class="w-full aspect-square rounded" :src="`https://picsum.photos/seed/${product.id}/60/60?blur=2`" /> </div> <div class="flex flex-col"> <div>{{ product.name }}</div> <div>{{ (product.price / 100).toFixed(2) }} €</div> </div> <div class="flex items-center ml-auto"> <button type="button" class="btn btn-secondary w-8 h-8 p-4" @click="removeProduct(product.uuid)" > — </button> </div> </div> </div> </div> </div> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mt-4"> <div class="p-6 text-gray-900 font-bold"> <div class="flex items-center justify-between text-2xl border-b pb-4"> <div>Total</div> <div>{{ ($page.props.cart.total / 100).toFixed(2) }} €</div> </div> <div class="mt-4" v-if="$page.props.cart.items.length"> <PrimaryButton type="button">Place Order</PrimaryButton> </div> </div> </div> </div> </div> </AuthenticatedLayout></template>
The removeProduct
function accepts uuid
as a parameter and passes it to the customer.cart.remove
route.
const removeProduct = (uuid) => { form.post(route('customer.cart.remove', uuid), { preserveScroll: true })}
And on the button click event, we pass it product uuid removeProduct(product.uuid)
.
<button type="button" class="btn btn-secondary w-8 h-8 p-4" @click="removeProduct(product.uuid)"> —</button>
We display the Place Order button only if we have items present in the cart.
<div class="mt-4" v-if="$page.props.cart.items.length"> <PrimaryButton type="button">Place Order</PrimaryButton></div>
Placing orders will be implemented next, and here's the final result of this lesson.