This example will show you how we built the system using Vue.js. For this, we will have three components:
Bonus: we will call the API Endpoint that adds products to the cart with no page reload.
Before we code anything in Vue, we need to install a few packages:
First, we have to install Breeze:
composer require laravel/breeze --dev
And then we need to run it's install:
php artisan breeze:install vue
In this install, we select the Vue Inertia
stack.
Once installed, we'll create our first Controller:
app/Http/Controllers/DashboardController.php
use App\Models\Cart;use App\Models\Category;use App\Models\Manufacturer;use App\Models\Product;use App\Services\PriceService;use Illuminate\Http\Request;use Inertia\Inertia; class DashboardController extends Controller{ public function __invoke(Request $request, PriceService $priceService) { $selected = $request->input('selected', [ 'prices' => [], 'categories' => [], 'manufacturers' => [] ]); $prices = $priceService->getPrices( [], $selected['categories'] ?? [], $selected['manufacturers'] ?? [] ); $categories = Category::withCount(['products' => function ($query) use ($selected) { $query->withFilters( $selected['prices'] ?? [], [], $selected['manufacturers'] ?? [] ); }]) ->get(); $manufacturers = Manufacturer::withCount(['products' => function ($query) use ($selected) { $query->withFilters( $selected['prices'] ?? [], $selected['categories'] ?? [], [] ); }]) ->get(); $products = Product::withFilters( $selected['prices'] ?? [], $selected['categories'] ?? [], $selected['manufacturers'] ?? [] )->get(); return Inertia::render('Dashboard', [ 'prices' => $prices, 'categories' => $categories, 'manufacturers' => $manufacturers, 'selected' => $selected, 'products' => $products, 'cart' => Cart::count(), 'cartProducts' => Cart::pluck('product_id')->unique()->toArray(), ]); }}
This Controller will load all the information we display on the page and pass it to the Inertia renderer.
And, of course, we need to add the route to the Controller instead of the default View:
routes/web.php
use App\Http\Controllers\DashboardController; // ... Route::get('/dashboard', function () { return view('dashboard');}); Route::get('/dashboard', DashboardController::class)->middleware(['auth', 'verified'])->name('dashboard');
Now, we can modify the Dashboard.vue
file that comes with Breeze. We add three not-yet-existing Vue components inside:
<products-list>
<product-filters>
<cart-count>
resources/js/Pages/Dashboard.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import {Head} from '@inertiajs/vue3';import ProductsList from "@/Components/products/ProductsList.vue";import ProductFilters from "@/Components/products/ProductFilters.vue";import CartCount from "@/Components/cart/CartCount.vue"; defineProps({ prices: { type: Array }, categories: { type: Array }, manufacturers: { type: Array }, products: { type: Array }, cart: { type: Number, }, cartProducts: { type: Array }, selected: { type: Object }})</script> <template> <Head title="Dashboard"/> <AuthenticatedLayout> <template #header> <div class="flex flex-grow justify-between items-center"> <h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200" > Dashboard </h2> <cart-count :cart="cart"/> </div> </template> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" > <div class="flex p-6"> <div class="w-1/3"> <product-filters :prices="prices" :categories="categories" :manufacturers="manufacturers" :selected="selected"/> </div> <div class="w-2/3"> <products-list :products="products" :cartProducts="cartProducts"/> </div> </div> </div> </div> </div> </AuthenticatedLayout></template>
Loading the page now would throw a lot of errors, because we don't have those component. So, let's build them.
Creating the Product list using Inertia is a bit different from Livewire. We don't have to create a PHP class file, instead - only the View:
resources/js/Components/products/ProductsList.vue
<script setup>import {Link} from '@inertiajs/vue3' defineProps({ products: { type: Array }, cartProducts: { type: Array }})</script> <template> <div class="flex flex-wrap"> <div class="w-1/3 p-3" v-for="(product, index) in products" :key="product.id + '_product'"> <div class="rounded-md"> <a href="#"><img src="http://placehold.it/700x400" alt=""></a> <div class="mt-3"> <a href="#" class="text-2xl text-indigo-500 hover:underline">{{ product.name }}</a> </div> <h5 class="mt-3">$ {{ product.price }}</h5> <p class="mt-3">{{ product.description }}</p> <div class="mt-4 border-t pt-6"> <Link :href="'/products/cart/add-or-remove/' + product.id" method="post" as="button" type="button" v-if="!cartProducts.includes(product.id)" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" href="#"> Add to Cart </Link> <Link :href="'/products/cart/add-or-remove/' + product.id" method="post" as="button" type="button" v-if="cartProducts.includes(product.id)" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" href="#"> Remove from Cart </Link> </div> </div> </div> </div></template>
But wait, that's a lot of code! What did we do here? Let's break it down!
We have defined our properties and their types that we need to render the list:
defineProps({ products: { type: Array }, cartProducts: { type: Array }})
Then, we have rendered a list of Products using a loop:
<div class="w-1/3 p-3" v-for="(product, index) in products" :key="product.id + '_product'"><!-- ... --></div>
Finally, we have used the Inertia component - Link
to display a link in SPA mode:
<Link :href="'/products/cart/add-or-remove/' + product.id" method="post" as="button" type="button" v-if="!cartProducts.includes(product.id)" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" href="#">Add to Cart</Link><Link :href="'/products/cart/add-or-remove/' + product.id" method="post" as="button" type="button" v-if="cartProducts.includes(product.id)" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" href="#">Remove from Cart</Link>
That's it! Seriously, this is all we had to do to render the list. The data will come later - when we call the component.
Let's add a route that will handle adding/removing products:
app/Http/Controllers/Api/AddOrRemoveProductController.php
use App\Http\Controllers\Controller;use App\Models\Cart; class AddOrRemoveProductController extends Controller{ public function __invoke(int $productID) { if (Cart::where('product_id', $productID)->exists()) { Cart::where('product_id', $productID)->delete(); } else { Cart::create(['product_id' => $productID]); } return redirect()->back(); }}
Then register the route:
routes/web.php
use App\Http\Controllers\Api\AddOrRemoveProductController; // ... Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::post('products/cart/add-or-remove/{productID}', AddOrRemoveProductController::class);});
This route will be called as soon as our User clicks on the button. However, the customer won't see any page refresh due to the Link
from Inertia being used.
Next is our filters. This one is a bit more complicated due to real-time updates:
resources/js/Components/products/ProductFilters.vue
<script setup>import { router } from '@inertiajs/vue3'; defineProps({ prices: { type: Array, }, categories: { type: Array, }, manufacturers: { type: Array, }, selected: { type: Object }}) const filterProducts = (e, type, selected = []) => { let updatedItems = [...(selected[type] || [])]; // Clone the selected prices if (e.target.checked) { updatedItems.push(e.target.value); // Add value if checked } else { updatedItems = updatedItems.filter(item => item !== e.target.value); // Remove value if unchecked } router.visit('/dashboard', { data: { 'selected[prices]': selected.prices || [], // Ensure prices are included 'selected[categories]': selected.categories || [], // Ensure categories are included 'selected[manufacturers]': selected.manufacturers || [], // Ensure manufacturers are included [`selected[${type}]`]: updatedItems // Update only the current type }, preserveScroll: true, except: ['cart', 'cartProducts'], })}</script> <template> <div class="col-lg-3 mb-4"> <form method="get"> <h1 class="mt-4 text-4xl">Filters</h1> <h3 class="mt-2 mb-1 text-3xl">Price</h3> <div v-for="(price, index) in prices" :key="price.id + '_prices'"> <input type="checkbox" :id="'price' + index" :value="index" name="selected[prices]" :checked="selected?.prices?.includes(index.toString())" v-on:change="(e) => filterProducts(e, 'prices', selected)" > <label :for="'price' + index"> {{ price.name }} ({{ price.products_count }}) </label> </div> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <div class="form-check" v-for="(category, index) in categories" :key="category.id + '_category'"> <input type="checkbox" :id="'category' + index" :value="category.id" name="selected[categories]" :checked="selected?.categories?.includes(category.id?.toString())" v-on:change="(e) => filterProducts(e, 'categories', selected)" > <label :for="'category' + index"> {{ category.name }} ({{ category.products_count }}) </label> </div> <h3 class="mt-2 mb-1 text-3xl">Manufacturers</h3> <div class="form-check" v-for="(manufacturer, index) in manufacturers" :key="manufacturer.id + '_manufacturer'"> <input type="checkbox" :id="'manufacturer' + index" :value="manufacturer.id" name="selected[manufacturers]" :checked="selected?.manufacturers?.includes(manufacturer.id?.toString())" v-on:change="(e) => filterProducts(e, 'manufacturers', selected)" > <label :for="'manufacturer' + index"> {{ manufacturer.name }} ({{ manufacturer.products_count }}) </label> </div> </form> </div> </template>
In this, we want to focus again on the defined properties:
defineProps({ prices: { type: Array, }, categories: { type: Array, }, manufacturers: { type: Array, }, selected: { type: Object }})
Then, we have to move to three loops that we have:
<div v-for="(price, index) in prices" :key="price.id + '_prices'"><input type="checkbox" :id="'price' + index" :value="index" name="selected[prices]" :checked="selected?.prices?.includes(index.toString())" v-on:change="(e) => filterProducts(e, 'prices', selected)"><label :for="'price' + index"> {{ price.name }} ({{ price.products_count }})</label></div> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <div class="form-check" v-for="(category, index) in categories" :key="category.id + '_category'"><input type="checkbox" :id="'category' + index" :value="category.id" name="selected[categories]" :checked="selected?.categories?.includes(category.id?.toString())" v-on:change="(e) => filterProducts(e, 'categories', selected)"><label :for="'category' + index"> {{ category.name }} ({{ category.products_count }})</label></div> <h3 class="mt-2 mb-1 text-3xl">Manufacturers</h3> <div class="form-check" v-for="(manufacturer, index) in manufacturers" :key="manufacturer.id + '_manufacturer'"><input type="checkbox" :id="'manufacturer' + index" :value="manufacturer.id" name="selected[manufacturers]" :checked="selected?.manufacturers?.includes(manufacturer.id?.toString())" v-on:change="(e) => filterProducts(e, 'manufacturers', selected)"><label :for="'manufacturer' + index"> {{ manufacturer.name }} ({{ manufacturer.products_count }})</label></div>
And finally, the block we missed was the filterProducts()
function. This one is called on each of our checkboxes:
v-on:change="(e) => filterProducts(e, 'manufacturers', selected)"
And the function itself:
const filterProducts = (e, type, selected = []) => { let updatedItems = [...(selected[type] || [])]; // Clone the selected prices if (e.target.checked) { updatedItems.push(e.target.value); // Add value if checked } else { updatedItems = updatedItems.filter(item => item !== e.target.value); // Remove value if unchecked } router.visit('/dashboard', { data: { 'selected[prices]': selected.prices || [], // Ensure prices are included 'selected[categories]': selected.categories || [], // Ensure categories are included 'selected[manufacturers]': selected.manufacturers || [], // Ensure manufacturers are included [`selected[${type}]`]: updatedItems // Update only the current type }, preserveScroll: true, except: ['cart', 'cartProducts'], })}
This code is a clever way to trigger a page update (partial reload) without reloading the page. We'll discuss this more later once our dashboard renders these components.
And finally, the most straightforward component that we have:
resources/js/Components/cart/CartCount.vue
<script setup>defineProps({ cart: { type: Number, },})</script> <template> <div class="px-4 py-3 leading-normal text-blue-700 bg-blue-100 rounded-lg text-right w-1/3"> <i class="fa fa-shopping-cart"></i> Cart ({{ cart }}) </div></template>
That's it. Our application should now load after running npm run build
.
Ok, the final step in the next lesson is trying to create the same thing with React + Inertia.
You can find code of this lesson here in the branch on GitHub