Back to Course |
Laravel Vue Inertia: Food Ordering Project Step-By-Step

Add Products to Cart

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.

Cart Add Item

Users can review cart contents and remove items from the cart page when pressing the View basket button.

Cart List

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:

Cart New Order

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.


Create Customer Role

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;
});
 
// ...
}

Add Product To Cart

Now that we have the customer role set up, we can continue working on "Add to cart" button on the public restaurant page.

Cart Add Single Item

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.

Cart New 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);

Side Note: Why UUID?

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.


Routing

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';

Vue Component

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) }} &euro; (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) }} &euro;
</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)
})
}
}

Cart New Order

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;
}

View Cart Button In Top Bar

Let's create a View basket button in the top bar to display the total sum of the cart.

View Basket Button

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) }} &euro;
</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.

Cart Press Add


List Cart And Remove Product

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) }} &euro;
</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) }} &euro;</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) }} &euro;</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.

Cart Final