Product Catalog & Cart with Laravel 8
Build a full-stack e-commerce storefront with cart and inventory tracking.
Introduction
30 Second Summary
Every time you add something to your cart on an online store, a system is working behind the scenes. It tracks your selections, calculates totals, and checks whether items are actually in stock.
In this project, you will build ShopFlow, a full-stack e-commerce storefront using Laravel 8 and PHP. You will create a product catalog, a session-based shopping cart, and an order checkout flow with real inventory tracking.
What You'll Build
You will open ShopFlow in your browser, browse a grid of products with prices and stock levels, add items to your cart, and place an order that instantly deducts inventory from the database.
By the end of this project, you'll have:
- A product catalog where you browse 12 products with prices and stock levels, and click into any product's full detail page.
- A shopping cart that persists across pages. Add products, update quantities, remove items, and watch the running total recalculate.
- An order checkout flow that validates stock before completing. Place an order and watch each product's stock level decrease by exactly what you ordered.
- Secret Mission: Add a product search bar and category filter to the catalog so customers can find products quickly.
Are there any prerequisites?
This project assumes you are comfortable with object-oriented PHP. You should have PHP 7.4 and Composer installed on your machine before starting.
Before We Start
Before you start building, take a moment to lock in what you're about to create and why it matters.
Set Up Your Laravel 8 Project
Before you can build any features for ShopFlow, you need a working Laravel 8 application with a database. Without this foundation, there is no way to create models, write migrations, or serve pages to the browser.
In this step, you will scaffold a fresh Laravel 8 project using Composer, configure SQLite as the database, and confirm the whole stack works by loading the Laravel welcome page.
In this step, get ready to:
- Scaffold a new Laravel 8 project called shopflow.
- Configure SQLite as the database and run default migrations.
- Start the development server and see the welcome page.
Scaffold the Laravel 8 project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Laravel 8 project by running this command:
composer create-project laravel/laravel:^8.0 shopflow
What does this command do?
composer create-project downloads a full copy of the Laravel 8 framework and all its dependencies into a new folder called shopflow.
This may take a minute or two depending on your internet connection. You will see Composer downloading and installing packages in the terminal output.
- When the command finishes, move into the project folder and confirm the files are there by running:
cd shopflow
ls
You should see files like artisan and composer.json, along with folders like app, database, and routes.
Seeing 'command not found'?
Make sure Composer is installed and available in your terminal. Run composer --version to check.
If composer is not recognized, revisit your Composer installation and ensure it is added to your system's PATH.
Help me troubleshoot Composer installation issues on my system.
Configure SQLite as the database
Laravel 8 defaults to MySQL, but ShopFlow uses SQLite. SQLite stores the entire database in a single file, so there is no server to install or configure.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the shopflow folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click on the .env file to open it.
- Find the database configuration section, which looks like this:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
- Delete all six of these lines and replace them with this single line:
DB_CONNECTION=sqlite
Why remove the other DB_ variables?
SQLite does not need a host, port, username, or password because it reads from a local file. Laravel automatically looks for database/database.sqlite when DB_CONNECTION is set to sqlite.
- Save the .env file.
- Open a terminal inside VS Code by clicking Terminal in the top menu bar, then clicking New Terminal.
- Create the empty SQLite database file by running this command:
touch database/database.sqlite
On Windows?
If your terminal does not recognize touch, right-click the database folder in the Explorer sidebar, select New File, and name it database.sqlite.
- Run the default Laravel migrations by running this command:
php artisan migrate
What does this command do?
php artisan migrate reads the migration files in database/migrations and creates the corresponding tables in your SQLite database.
Laravel ships with default migrations for users, password resets, and failed jobs. These tables are part of the framework's foundation.
You should see output confirming that each migration ran successfully, creating tables for users, password resets, and failed jobs.
Seeing a database driver error?
If you see an error mentioning a missing database driver, the SQLite PHP extension is not enabled. Open your php.ini file and uncomment the line containing pdo_sqlite.
Double-check that database/database.sqlite exists inside your shopflow folder. The file must be created before you run the migrate command.
Help me fix a SQLite driver error in my PHP setup.
Start the development server
Everything is wired up. Time to see your project in action.
What do you think will appear when you start the server and visit http://localhost:8000?
- Start the Laravel development server by running this command:
php artisan serve
Keep this terminal running
This command starts a local web server and takes over the terminal. Leave it running while you work on the project. To stop it later, press Ctrl+C.
Whenever you need to run another terminal command, open a new terminal tab in VS Code by clicking Terminal then New Terminal.
- Open your browser.
- Navigate to http://localhost:8000.
The Laravel welcome page appears with the Laravel logo and links to the documentation. This confirms your PHP, Composer, and database are all wired correctly.
Don't see the welcome page?
Make sure the php artisan serve command is still running in your terminal. If you closed it or pressed Ctrl+C, run it again.
If port 8000 is already in use by another process, stop that process and try running the command again.
Help me troubleshoot the Laravel development server not loading.
Your Laravel 8 project is ready with a working database. Next up, you will build the product catalog that powers ShopFlow's storefront.
Build the Product Catalog
Your dev server is up and the Laravel welcome page confirms your PHP, Composer, and Laravel stack are all connected. Now it's time to fill the store with something to sell.
Every e-commerce app starts with products. In this step, you'll walk through the full Eloquent model lifecycle: define a model, write a database migration, seed data with a factory, and render it all through a controller and Blade views.
In this step, get ready to:
- Create a Product model with a migration, factory, and seeder.
- Build a ProductController with routes for browsing products.
- Design Blade views to display the product catalog.
Generate the Product model and seed data
Laravel's Artisan CLI can scaffold a model and all its related files in a single command. The -mfs flags tell it to generate a migration, factory, and seeder alongside the model.
- Open a new terminal tab so your dev server keeps running in the first one.
- Generate the Product model and its related files by running this command from your shopflow directory:
php artisan make:model Product -mfs
You should see output confirming four files were created: the model, migration, factory, and seeder.
What do the -mfs flags do?
-m creates a database migration file that defines the table structure.
-f creates a factory class for generating fake product data.
-s creates a seeder class that populates the database using the factory.
The migration defines what columns your products table will have. Open the newly created migration file inside database/migrations/. Its filename ends with _create_products_table.php.
- Replace the entire contents of the migration file with this code:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProductsTable extends Migration
{
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description');
$table->decimal('price', 8, 2);
$table->unsignedInteger('stock')->default(0);
$table->string('image_url')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('products');
}
}
What does this migration do?
- string('name') stores the product's display name.
- decimal('price', 8, 2) stores prices with 2 decimal places and up to 8 total digits.
- unsignedInteger('stock') tracks how many units are available. It defaults to 0.
- image_url is nullable because you won't use product images in this project.
- timestamps() adds created_at and updated_at columns automatically.
- Save the migration file.
- Run the migration to create the products table by running:
php artisan migrate
You should see output that includes Migrating and Migrated lines for the create_products_table migration.
Migration failed?
- Make sure your .env file still has DB_CONNECTION=sqlite and that database/database.sqlite exists in your project.
- Check for syntax errors in the migration file. Missing semicolons or mismatched parentheses are common culprits.
Ask for help: Why is my Laravel migration failing with SQLite?
Now tell the model which columns it can work with. Open app/Models/Product.php in VS Code.
- Replace the entire contents of Product.php with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'price',
'stock',
'image_url',
];
protected $casts = [
'price' => 'decimal:2',
'stock' => 'integer',
];
}
What does this model do?
$fillable lists the columns you can mass-assign when creating or updating a product. This protects against unwanted data being saved.
$casts tells Eloquent to automatically convert price to a two-decimal number and stock to an integer whenever you read them from the database.
- Save Product.php.
Next, configure the factory to generate realistic product data. Open database/factories/ProductFactory.php.
- Replace the entire contents of ProductFactory.php with this class definition and product names array:
<?php
namespace Database\Factories;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition()
{
// List of realistic product names
$products = [
'Wireless Headphones',
'Laptop Stand',
'Mechanical Keyboard',
'USB-C Hub',
'Webcam HD',
'Desk Lamp',
'Mouse Pad XL',
'Monitor Arm',
'Cable Organizer',
'Phone Stand',
'Portable Charger',
'Bluetooth Speaker',
];
- Directly below the closing ]; of the $products array, add the return statement that generates fake data for each column:
return [
'name' => $this->faker->unique()->randomElement($products),
'description' => $this->faker->paragraph(2),
'price' => $this->faker->randomFloat(2, 9.99, 199.99),
'stock' => $this->faker->numberBetween(5, 50),
'image_url' => null,
];
}
}
What does this factory do?
unique()->randomElement($products) picks a product name from the array without repeating. This gives you 12 distinct products.
randomFloat(2, 9.99, 199.99) generates a price between $9.99 and $199.99 with two decimal places. numberBetween(5, 50) gives each product a random stock level.
- Save ProductFactory.php.
Test that the model and factory work together before seeding the database.
- Open Tinker, Laravel's interactive console, by running:
php artisan tinker
- Generate a test product without saving it to the database by running:
App\Models\Product::factory()->make();
You should see a Product object with randomized values for name, description, price, and stock. This confirms the model and factory are wired correctly.
- Exit Tinker by typing exit and pressing Enter.
Tinker showing an error?
- If you see a class not found error, make sure you saved both Product.php and ProductFactory.php before opening Tinker.
- Check that ProductFactory.php includes use App\Models\Product; at the top.
Ask for help: Why is Tinker not finding my Product model or factory?
Now wire up the seeder so you can populate the database with 12 products in one command.
- Open database/seeders/ProductSeeder.php and replace its contents with this code:
<?php
namespace Database\Seeders;
use App\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
public function run()
{
// Create 12 products using the factory
Product::factory()->count(12)->create();
}
}
- Save ProductSeeder.php.
- Open database/seeders/DatabaseSeeder.php and replace its contents to register your ProductSeeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run()
{
// Call the ProductSeeder to populate the products table
$this->call([
ProductSeeder::class,
]);
}
}
How does the seeder chain work?
DatabaseSeeder is the entry point that Laravel calls when you run php artisan db:seed. It delegates to ProductSeeder, which uses your factory to create 12 products and save them to the database.
- Save DatabaseSeeder.php.
- Seed the database by running:
php artisan db:seed
You should see Database seeding completed successfully. in the terminal output.
Seeding failed?
- If you see a table not found error, confirm that php artisan migrate ran successfully earlier. The products table must exist before seeding.
- Check that ProductSeeder.php has use App\Models\Product; at the top.
Ask for help: Why is my Laravel db:seed command failing?
Create the ProductController and register routes
With 12 products in the database, you need a controller to fetch them and routes to make them reachable from the browser.
- Generate a resource controller by running:
php artisan make:controller ProductController --resource
This creates app/Http/Controllers/ProductController.php with empty stub methods for standard CRUD actions.
- Open app/Http/Controllers/ProductController.php and replace its entire contents with this code:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
class ProductController extends Controller
{
public function index()
{
// Fetch all products from the database
$products = Product::all();
return view('products.index', compact('products'));
}
public function show(Product $product)
{
// Route model binding: Laravel auto-finds the product by ID
return view('products.show', compact('product'));
}
}
What is route model binding?
The show(Product $product) method type-hints the Product model. Laravel automatically finds the product matching the ID in the URL and passes it to the method. No manual Product::find($id) needed.
- Save ProductController.php.
- Open routes/web.php and replace its entire contents with the following routes:
<?php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
// Redirect home to the product catalog
Route::get('/', function () {
return redirect()->route('products.index');
});
// Product catalog routes
Route::resource('products', ProductController::class)->only(['index', 'show']);
// Placeholder cart routes (replaced in Step 3)
Route::get('/cart', function () {
return redirect()->route('products.index');
})->name('cart.index');
Route::post('/cart/add/{product}', function () {
return redirect()->back();
})->name('cart.add');
What do these routes do?
Route::resource('products', ...)->only(['index', 'show']) registers two routes: GET /products for the catalog listing and GET /products/{product} for individual product pages.
The two placeholder cart routes give the cart.index and cart.add names a destination so the views you build next can reference them. You will replace these with real cart logic in Step 3.
- Save web.php.
- Verify the routes are registered by running:
php artisan route:list
You should see entries for products.index and products.show in the output, along with the cart placeholder routes.
Routes not showing up?
- Check that routes/web.php has use App\Http\Controllers\ProductController; at the top. Missing this import causes a class not found error.
- Make sure you saved the file after editing.
Ask for help: Why are my Laravel routes not appearing in route:list?
Create Blade views and display the catalog
The controller returns views that don't exist yet. You need a layout template and two product views to bring the catalog to life.
- Create the view directories by running:
mkdir -p resources/views/layouts resources/views/products
- Confirm the directories were created by running:
ls resources/views/
You should see layouts and products listed alongside the existing welcome.blade.php file.
The layout template defines the HTML wrapper shared by every page. It includes Tailwind CSS via CDN, a navigation bar, and flash message areas.
- Create a new file at resources/views/layouts/app.blade.php and paste the head and navigation section:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShopFlow - @yield('title')</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm">
<div class="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<a href="{{ route('products.index') }}" class="text-xl font-bold text-gray-800">ShopFlow</a>
<a href="{{ route('cart.index') }}" class="text-gray-600 hover:text-gray-800">
Cart ({{ array_sum(array_column(session('cart', []), 'quantity')) }})
</a>
</div>
</nav>
- Directly below, paste the main content area and closing tags:
<main class="max-w-6xl mx-auto px-4 py-8">
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-6">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{{ session('error') }}
</div>
@endif
@yield('content')
</main>
</body>
</html>
What does this layout do?
@yield('title') and @yield('content') are Blade placeholders that child views fill in. Each page provides its own title and body content.
The nav bar shows the app name and a cart link with a live item count pulled from the session. The flash message blocks display success and error notifications after actions like adding to cart.
- Save app.blade.php.
Now create the product listing page that displays all 12 products in a responsive grid.
- Create a new file at resources/views/products/index.blade.php and paste this code:
@extends('layouts.app')
@section('title', 'Products')
@section('content')
<h1 class="text-2xl font-bold mb-6">Products</h1>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
@foreach($products as $product)
<div class="bg-white rounded-lg shadow-sm p-4">
<h2 class="font-semibold text-lg mb-2">{{ $product->name }}</h2>
<p class="text-gray-600 text-sm mb-3">{{ Str::limit($product->description, 80) }}</p>
<div class="flex justify-between items-center mb-3">
<span class="text-lg font-bold text-green-600">${{ number_format($product->price, 2) }}</span>
<span class="text-sm text-gray-500">Stock: {{ $product->stock }}</span>
</div>
<div class="flex gap-2">
<a href="{{ route('products.show', $product) }}" class="flex-1 text-center bg-gray-200 text-gray-800 py-2 rounded hover:bg-gray-300 text-sm">View</a>
<form action="{{ route('cart.add', $product) }}" method="POST" class="flex-1">
@csrf
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 text-sm">Add to Cart</button>
</form>
</div>
</div>
@endforeach
</div>
@endsection
What does this view do?
@extends('layouts.app') wraps this page in your layout template. The @foreach loop renders a card for each product.
Str::limit($product->description, 80) truncates long descriptions to 80 characters. Each card shows the price, stock level, a View link, and an Add to Cart button.
- Save index.blade.php.
- Open your browser and visit http://localhost:8000/products.
You should see a grid of 12 product cards, each with a name, truncated description, price in green, and a stock count.
Seeing an error instead of products?
- If you see a "View not found" error, double-check that index.blade.php is inside resources/views/products/ (not resources/views/ directly).
- If you see a blank page, confirm that php artisan db:seed ran successfully and populated 12 products.
- If the dev server stopped, restart it with php artisan serve in a separate terminal tab.
Ask for help: Why is my Laravel product catalog page showing an error?
The last view is the individual product detail page.
- Create a new file at resources/views/products/show.blade.php and paste this code:
@extends('layouts.app')
@section('title', $product->name)
@section('content')
<a href="{{ route('products.index') }}" class="text-blue-600 hover:underline mb-4 inline-block">← Back to Products</a>
<div class="bg-white rounded-lg shadow-sm p-6">
<h1 class="text-2xl font-bold mb-2">{{ $product->name }}</h1>
<p class="text-gray-600 mb-4">{{ $product->description }}</p>
<div class="flex items-center gap-6 mb-6">
<span class="text-2xl font-bold text-green-600">${{ number_format($product->price, 2) }}</span>
<span class="text-gray-500">{{ $product->stock }} in stock</span>
</div>
@if($product->stock > 0)
<form action="{{ route('cart.add', $product) }}" method="POST">
@csrf
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700">Add to Cart</button>
</form>
@else
<p class="text-red-600 font-semibold">Out of Stock</p>
@endif
</div>
@endsection
What does the show view do?
This page displays the full product detail. It shows the complete description (no truncation), the price, and the stock count.
The @if($product->stock > 0) conditional checks whether items are available. If stock is zero, the Add to Cart button is replaced with an "Out of Stock" message.
- Save show.blade.php.
Before you test the detail page, think about what you expect to see when you click a product's View button. What information should appear on the detail page that was truncated on the catalog?
- Return to http://localhost:8000/products in your browser.
- Click the View button on any product card.
You should see the product's full name, complete description, price, stock level, and an Add to Cart button. A back link at the top returns you to the catalog.
Detail page not loading?
- Make sure show.blade.php is saved inside resources/views/products/.
- Check that the file starts with @extends('layouts.app') and has no syntax errors in the Blade directives.
Ask for help: Why is my Laravel product detail page not rendering?
✔️ Awesome, I've got everything!
Great. Double-check that you saved all files before continuing.
ⓧ I'd like to double check the full code
You created several files in this step. Scroll up and confirm each one is present and saved:
- database/migrations/...create_products_table.php with 7 column definitions.
- app/Models/Product.php with $fillable and $casts.
- database/factories/ProductFactory.php with the 12-item $products array and faker return values.
- database/seeders/ProductSeeder.php calling Product::factory()->count(12)->create().
- database/seeders/DatabaseSeeder.php calling ProductSeeder::class.
- app/Http/Controllers/ProductController.php with index() and show() methods.
- routes/web.php with resource routes and placeholder cart routes.
- resources/views/layouts/app.blade.php with nav bar and flash messages.
- resources/views/products/index.blade.php with the product grid.
- resources/views/products/show.blade.php with the detail page.
Your storefront has products to browse and individual detail pages to explore. Next up, you'll build a shopping cart so customers can save their selections and see a running total.
Add a Shopping Cart
Your product catalog is live with 12 products on display. But right now, there is no way for a customer to save products as they browse. Clicking Add to Cart has nowhere to send the request.
A store needs a way to hold onto selections across page loads. In this step, you will build a session-based shopping cart using Laravel sessions. Sessions store data on the server that persists across HTTP requests for a single visitor.
In this step, get ready to:
- Create a CartController with session-based add, update, and remove operations.
- Register cart routes and build a cart Blade view with quantity controls and a running total.
- Test the full add, update, and remove cart flow in your browser.
Create the CartController
The cart will live entirely in the user's session. No new database table is needed. Each session stores an array of product IDs mapped to quantities.
- Generate a new controller by running this command in your terminal:
php artisan make:controller CartController
- Confirm the file was created by running:
ls app/Http/Controllers/CartController.php
You should see the file path printed back. The generated file is mostly empty, so you will replace its contents next.
Command not found?
Make sure you are inside the shopflow folder when running php artisan commands. Run cd ~/Desktop/shopflow first if needed.
Help me fix my artisan command.
- Open app/Http/Controllers/CartController.php in VS Code and replace its entire contents with this code for the index() method:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function index()
{
// Read cart data from the session
$cart = session()->get('cart', []);
// Load only the products whose IDs are in the cart
$products = Product::whereIn('id', array_keys($cart))->get();
$total = 0;
// Attach quantity from session and calculate running total
foreach ($products as $product) {
$product->cart_quantity = $cart[$product->id]['quantity'];
$total += $product->price * $product->cart_quantity;
}
return view('cart.index', compact('products', 'total'));
}
}
What does this code do?
- session()->get('cart', []) reads the cart array from the server-side session. If no cart exists yet, it returns an empty array.
- Product::whereIn('id', array_keys($cart)) loads only the products that match the IDs stored in the cart. This avoids loading all 12 products when only 2 are in the cart.
- The foreach loop attaches each product's cart quantity as a dynamic property and adds up the running total.
- Add the add() method inside the CartController class, right below the closing } of the index() method:
public function add(Product $product)
{
// Read existing cart from session
$cart = session()->get('cart', []);
// Increment if already in cart, otherwise add with quantity 1
if (isset($cart[$product->id])) {
$cart[$product->id]['quantity']++;
} else {
$cart[$product->id] = ['quantity' => 1];
}
// Save updated cart back to session
session()->put('cart', $cart);
return redirect()->route('cart.index')
->with('success', $product->name . ' added to cart!');
}
What does the add method do?
The method uses route model binding to automatically load the Product from the URL. If the product is already in the cart, its quantity increments by one. Otherwise, a new entry is created with a quantity of 1.
After updating the session, the method redirects to the cart page with a success flash message.
- Save CartController.php.
Register cart routes and build the cart view
The controller methods exist, but Laravel does not know which URLs map to them yet. You also need a Blade template so the cart page has something to render.
- Open routes/web.php in VS Code.
- Find this line near the top of the file:
use App\Http\Controllers\ProductController;
- Add this import directly below it:
use App\Http\Controllers\CartController;
- Scroll to the bottom of the file, below the Route::resource line. Add the four cart routes:
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add/{product}', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update/{product}', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove/{product}', [CartController::class, 'remove'])->name('cart.remove');
What do these routes do?
Each route maps an HTTP method and URL to a controller method. GET /cart displays the cart. POST /cart/add/{product} handles the Add to Cart form submissions.
The PATCH and DELETE methods follow REST conventions for updating and removing items. The {product} parameter uses route model binding to automatically load the matching Product.
- Save routes/web.php.
✔️ Awesome, I've got everything!
Great. Double check you have saved routes/web.php.
ⓧ I'd like to double check the full code
<?php
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->route('products.index');
});
Route::resource('products', ProductController::class)->only(['index', 'show']);
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add/{product}', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update/{product}', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove/{product}', [CartController::class, 'remove'])->name('cart.remove');
Now you need a Blade template for the cart page. The view shows an empty-cart message if nothing has been added, or a full table of items with quantity controls.
- Create a new folder called cart inside resources/views/ by running:
mkdir resources/views/cart
- Create the cart view file by running:
touch resources/views/cart/index.blade.php
- Confirm both were created by running:
ls resources/views/cart/
You should see index.blade.php listed in the output.
- Open resources/views/cart/index.blade.php in VS Code and paste this code to set up the page structure and empty-cart state:
@extends('layouts.app')
@section('title', 'Cart')
@section('content')
<h1 class="text-2xl font-bold mb-6">Shopping Cart</h1>
@if($products->isEmpty())
<div class="bg-white rounded-lg shadow-sm p-6 text-center">
<p class="text-gray-500 mb-4">Your cart is empty.</p>
<a href="{{ route('products.index') }}" class="text-blue-600 hover:underline">Browse Products</a>
</div>
@else
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Product</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Price</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Quantity</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Subtotal</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
What does this code do?
The view extends your existing layout and checks if the cart's product collection is empty. If so, it shows a friendly message with a link back to the catalog. Otherwise, it begins a table with column headers for Product, Price, Quantity, Subtotal, and an actions column.
- Directly below the code you just pasted, add the table body with each cart item's row, the total, and the closing tags:
<tbody class="divide-y divide-gray-200">
@foreach($products as $product)
<tr>
<td class="px-6 py-4 font-medium">{{ $product->name }}</td>
<td class="px-6 py-4">${{ number_format($product->price, 2) }}</td>
<td class="px-6 py-4">
<form action="{{ route('cart.update', $product) }}" method="POST" class="flex items-center gap-2">
@csrf
@method('PATCH')
<input type="number" name="quantity" value="{{ $product->cart_quantity }}" min="1" class="w-16 border rounded px-2 py-1 text-center">
<button type="submit" class="text-blue-600 hover:underline text-sm">Update</button>
</form>
</td>
<td class="px-6 py-4">${{ number_format($product->price * $product->cart_quantity, 2) }}</td>
<td class="px-6 py-4">
<form action="{{ route('cart.remove', $product) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline text-sm">Remove</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="px-6 py-4 bg-gray-50 flex justify-between items-center">
<span class="text-lg font-bold">Total: ${{ number_format($total, 2) }}</span>
<a href="{{ route('checkout') }}" class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700">Proceed to Checkout</a>
</div>
</div>
@endif
@endsection
What does this code do?
- Each row shows the product name, price, a quantity input with an Update button, the subtotal for that item, and a Remove button.
- The @method('PATCH') and @method('DELETE') directives are required because HTML forms only support GET and POST. Laravel uses a hidden field to spoof the correct HTTP verb.
- The footer bar shows the running total and a Proceed to Checkout link (you will build that route in the next step).
- Save index.blade.php.
✔️ Awesome, I've got everything!
Great. Make sure you have saved resources/views/cart/index.blade.php.
ⓧ I'd like to double check the full code
The full index.blade.php has two sections you pasted above. Scroll up and confirm both are present in this order:
- The page structure chunk: starts with @extends('layouts.app') and ends with </thead>.
- The table body chunk: starts with <tbody and ends with @endsection.
Time to see it in action. The Add to Cart buttons in the product catalog are already wired to submit to the cart.add route from the previous step.
- Visit http://localhost:8000/products in your browser.
- Click Add to Cart on any product.
You should be redirected to the cart page. A green success message appears at the top, and the product shows up in the cart table with a quantity of 1, its price, and a subtotal.
Seeing a route not defined error?
Make sure you saved both routes/web.php and CartController.php. Check that the use App\Http\Controllers\CartController; import line is at the top of your routes file.
Help me debug my cart route error.
Add update and remove actions
Adding products works. But the cart also needs to let customers change quantities and remove items entirely. The view already has Update and Remove buttons that submit forms to cart.update and cart.remove. You need to add the matching controller methods.
- Open app/Http/Controllers/CartController.php in VS Code.
- Add the update() and remove() methods inside the class, below the closing } of the add() method:
public function update(Product $product, Request $request)
{
$cart = session()->get('cart', []);
// Cast the form input to an integer
$quantity = (int) $request->input('quantity');
// Update quantity or remove if zero
if ($quantity > 0) {
$cart[$product->id]['quantity'] = $quantity;
} else {
unset($cart[$product->id]);
}
session()->put('cart', $cart);
return redirect()->route('cart.index');
}
public function remove(Product $product)
{
$cart = session()->get('cart', []);
// Delete the product entry from the cart
unset($cart[$product->id]);
session()->put('cart', $cart);
return redirect()->route('cart.index');
}
What do these methods do?
The update() method reads the new quantity from the form input. If the quantity is greater than zero, it updates the session. If the learner sets the quantity to zero, the product is removed from the cart entirely.
The remove() method deletes the product's entry from the session cart array using unset(). Both methods redirect back to the cart page so the learner sees the updated state immediately.
- Save CartController.php.
✔️ Awesome, I've got everything!
Great. Double check you have saved CartController.php.
ⓧ I'd like to double check the full code
The full CartController.php has four methods you built across this step. Scroll up and confirm each is present in this order:
- The file header and index() method: starts with <?php and ends with the closing } of index().
- The add() method: starts with public function add and ends with its closing }.
- The update() and remove() methods: added in this substep, ending with the class closing }.
Before you test, think about this: if you change a product's quantity from 1 to 3 in the cart, what do you expect to happen to the subtotal and total?
- Return to your browser and visit http://localhost:8000/products.
- Click Add to Cart on two different products.
- On the cart page, change one product's quantity to 3 and click Update.
The subtotal for that row triples and the total at the bottom recalculates. The nav bar cart count also updates to reflect the new total quantity.
- Click Remove on one of the products.
The product disappears from the cart table. If you remove everything, the page shows the "Your cart is empty" message with a link back to the catalog.
Update or Remove not working?
If clicking Update or Remove shows an error, make sure the update() and remove() methods are inside the CartController class (before the final }), not after it. Also confirm the file is saved.
Help me fix my cart update or remove.
Why sessions instead of a database table?
A session-based cart avoids creating an extra database table and model. It also sidesteps authentication. Without user accounts, there is no way to associate a database cart record with a specific visitor.
Sessions are stored on the server and tied to a browser cookie. As long as the browser stays open and the cookie is valid, the cart persists across page loads.
Your shopping cart is fully functional. Customers can add products, adjust quantities, and remove items. Next up, you will build the checkout flow that turns a cart into an order.
Place an Order
Your shopping cart is working. Customers can browse the catalog, toss products into their cart, and adjust quantities. But a cart with no checkout is just a wishlist.
In this step, you will build the order checkout flow. You will create Order and OrderItem models backed by database tables, wire up a controller that turns cart contents into a saved order, and build Blade views for checkout and confirmation.
In this step, get ready to:
- Create Order and OrderItem models with database migrations and Eloquent relationships.
- Build an OrderController with checkout, store, and show methods.
- Create checkout and confirmation Blade views and place a test order.
Create the Order and OrderItem models
An order needs two tables. One table stores the order itself (total, status). A second table stores each line item (which product, how many, at what price).
In Eloquent terms, an Order hasMany OrderItems. Each OrderItem belongsTo an Order and a Product.
- Generate the Order model with its migration by running this command:
php artisan make:model Order -m
- Generate the OrderItem model with its migration by running this command:
php artisan make:model OrderItem -m
What does the -m flag do?
The -m flag tells Artisan to create a database migration file alongside the model. Without it, you would need to generate the migration separately.
You should see two new migration files in database/migrations/ and two new model files in app/Models/.
- In VS Code, open the new migration file ending in _create_orders_table.php from the database/migrations/ folder.
- Replace the file's contents with the following migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOrdersTable extends Migration
{
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
// Status defaults to 'completed' for this project
$table->string('status')->default('completed');
$table->decimal('total', 10, 2);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('orders');
}
}
What does this migration do?
The orders table stores each placed order. The status column defaults to completed since this project has no payment processing. The total column uses decimal(10, 2) for precise currency values.
- Save _create_orders_table.php.
- Now open the migration file ending in _create_order_items_table.php.
- Replace the file's contents with the following migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOrderItemsTable extends Migration
{
public function up()
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
// Foreign keys link each item to its order and product
$table->foreignId('order_id')->constrained()->onDelete('cascade');
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->unsignedInteger('quantity');
$table->decimal('price', 8, 2);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('order_items');
}
}
What does this migration do?
Each row in order_items represents one product line in an order. The foreignId()->constrained()->onDelete('cascade') calls create foreign key constraints. If an order is deleted, its items are automatically removed too.
The price column captures the product price at the time of purchase. This protects order history if a product's price changes later.
- Save _create_order_items_table.php.
- Run both migrations by executing this command:
php artisan migrate
You should see output confirming that both the orders and order_items tables were created.
Migration failed?
Check that your database/database.sqlite file still exists. If you see a syntax error, double-check that the foreignId lines match the code above exactly.
Help me debug this migration error
- Now open app/Models/Order.php in VS Code.
- Replace the file's contents with the following model code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
protected $fillable = [
'status',
'total',
];
protected $casts = [
'total' => 'decimal:2',
];
// An order has many line items
public function items()
{
return $this->hasMany(OrderItem::class);
}
}
What does this model do?
The $fillable array tells Eloquent which columns can be mass-assigned when calling Order::create(). The items() method defines the hasMany relationship, so you can access all line items with $order->items.
- Save Order.php.
- Open app/Models/OrderItem.php.
- Replace the file's contents with the following model code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OrderItem extends Model
{
use HasFactory;
protected $fillable = [
'order_id',
'product_id',
'quantity',
'price',
];
protected $casts = [
'price' => 'decimal:2',
'quantity' => 'integer',
];
// Each item belongs to one order
public function order()
{
return $this->belongsTo(Order::class);
}
// Each item references one product
public function product()
{
return $this->belongsTo(Product::class);
}
}
What does this model do?
OrderItem has two belongsTo relationships. The order() method links back to the parent order. The product() method links to the product that was purchased. Together these let you traverse from order to items to products using Eloquent.
- Save OrderItem.php.
- Confirm the models load correctly by running this command:
php artisan tinker --execute="echo App\Models\Order::count() . ' orders, ' . App\Models\OrderItem::count() . ' items';"
You should see 0 orders, 0 items printed. The tables exist and the models load without errors.
Seeing a class not found error?
Make sure you saved both Order.php and OrderItem.php. Check that the namespace at the top of each file is App\Models and the class name matches the filename.
Help me debug this class not found error
Build the OrderController and routes
With the models in place, you need a controller to orchestrate the checkout flow. It will read the cart from the session, create order records, and render views.
- Generate the controller by running this command:
php artisan make:controller OrderController
- Open the newly created app/Http/Controllers/OrderController.php in VS Code.
- Replace the file's contents with the imports, class declaration, and checkout() method:
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\Request;
class OrderController extends Controller
{
// Display the cart summary before placing an order
public function checkout()
{
$cart = session()->get('cart', []);
if (empty($cart)) {
return redirect()->route('products.index')
->with('error', 'Your cart is empty.');
}
$products = Product::whereIn('id', array_keys($cart))->get();
$total = 0;
foreach ($products as $product) {
$product->cart_quantity = $cart[$product->id]['quantity'];
$total += $product->price * $product->cart_quantity;
}
return view('orders.checkout', compact('products', 'total'));
}
What does checkout() do?
It reads the cart from the session. If the cart is empty, it redirects back to the product catalog with an error flash message. Otherwise it loads the matching products, calculates the total, and passes everything to a checkout view.
- Below the checkout() method's closing brace, add the store() method:
// Create order records from the session cart
public function store(Request $request)
{
$cart = session()->get('cart', []);
$products = Product::whereIn('id', array_keys($cart))->get();
$total = 0;
foreach ($products as $product) {
$total += $product->price * $cart[$product->id]['quantity'];
}
$order = Order::create([
'status' => 'completed',
'total' => $total,
]);
foreach ($products as $product) {
$quantity = $cart[$product->id]['quantity'];
$order->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'price' => $product->price,
]);
}
session()->forget('cart');
return redirect()->route('orders.show', $order)
->with('success', 'Order placed successfully!');
}
What does store() do?
It reads the cart, calculates the total, and creates an Order record. Then it loops through each product in the cart and creates an OrderItem using the items() relationship. Finally it clears the cart from the session and redirects to the confirmation page.
Notice that this method does not check whether enough stock exists. It also does not reduce the product's stock count after the order is placed. You will see the consequences of this shortly.
- Below the store() method, add the show() method and the closing class brace:
// Load the confirmed order with its items
public function show(Order $order)
{
$order->load('items.product');
return view('orders.confirmation', compact('order'));
}
}
What is route model binding?
The type-hint Order $order in the method signature tells Laravel to automatically look up the Order by its ID from the URL. If the order does not exist, Laravel returns a 404.
The load('items.product') call eager-loads the related items and their products in a single query, avoiding the performance trap of loading each relationship one at a time.
- Save OrderController.php.
Now you need to register the routes so Laravel knows how to reach each controller method.
- Open routes/web.php.
- Find this line near the top of the file:
use App\Http\Controllers\CartController;
- Add the OrderController import directly below it, so the two lines look like this:
use App\Http\Controllers\CartController;
use App\Http\Controllers\OrderController;
- Scroll to the bottom of the file, below the cart routes. Add these three order routes:
Route::get('/checkout', [OrderController::class, 'checkout'])->name('checkout');
Route::post('/orders', [OrderController::class, 'store'])->name('orders.store');
Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');
What do these routes do?
The GET /checkout route displays the checkout page. POST /orders handles the form submission that creates the order. GET /orders/{order} shows the confirmation page, with {order} matching the route model binding in the controller.
- Save routes/web.php.
- Verify the routes are registered by running this command:
php artisan route:list --path=order
You should see three routes listed: GET checkout, POST orders, and GET orders/{order}.
Routes not showing up?
Make sure you added the use App\Http\Controllers\OrderController; import at the top of routes/web.php. Check that each route definition ends with a semicolon.
Help me debug my order routes
Create checkout and confirmation views
The controller methods reference two views that do not exist yet. You will create the checkout page (order summary with a Place Order button) and the confirmation page (order receipt after purchase).
- Create the orders views directory and both files by running these commands:
mkdir resources/views/orders
touch resources/views/orders/checkout.blade.php
touch resources/views/orders/confirmation.blade.php
- Confirm the files exist by running:
ls resources/views/orders/
You should see checkout.blade.php and confirmation.blade.php listed.
- Open resources/views/orders/checkout.blade.php in VS Code.
- Add the order summary table that shows each cart item:
@extends('layouts.app')
@section('title', 'Checkout')
@section('content')
<h1 class="text-2xl font-bold mb-6">Checkout</h1>
{{-- Order summary table with product details --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden mb-6">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Product</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Price</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Quantity</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Subtotal</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($products as $product)
<tr>
<td class="px-6 py-4 font-medium">{{ $product->name }}</td>
<td class="px-6 py-4">${{ number_format($product->price, 2) }}</td>
<td class="px-6 py-4">{{ $product->cart_quantity }}</td>
<td class="px-6 py-4">${{ number_format($product->price * $product->cart_quantity, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
What does this code do?
This is the top half of the checkout page. It extends the app layout and renders a table row for each product in the cart, showing the name, unit price, quantity, and subtotal. The cart_quantity property was set by the controller.
- Directly below the code you just added (after the closing </table> tag), add the total bar, Place Order form, and closing tags:
{{-- Total bar with the Place Order form --}}
<div class="px-6 py-4 bg-gray-50 flex justify-between items-center">
<span class="text-lg font-bold">Total: ${{ number_format($total, 2) }}</span>
<form action="{{ route('orders.store') }}" method="POST">
@csrf
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700">Place Order</button>
</form>
</div>
</div>
<a href="{{ route('cart.index') }}" class="text-blue-600 hover:underline">← Back to Cart</a>
@endsection
What does this code do?
The total bar shows the order total. The form posts to the orders.store route when you click Place Order. The @csrf directive adds a security token that Laravel requires on all POST forms.
- Save checkout.blade.php.
- Verify the checkout page loads by visiting http://localhost:8000/products in your browser.
- Click Add to Cart on any product.
- Click Proceed to Checkout on the cart page.
You should see the checkout page with a table listing the product, its price, quantity, and subtotal, plus a Place Order button.
Seeing a blank page or an error?
Make sure the dev server is still running. If it stopped, restart it with php artisan serve. Check that checkout.blade.php is saved inside resources/views/orders/ and that the filename matches exactly.
Help me debug my checkout page
Do not click Place Order yet. You need to build the confirmation view first.
- Open resources/views/orders/confirmation.blade.php.
- Add the order confirmation header and items table:
@extends('layouts.app')
@section('title', 'Order Confirmed')
@section('content')
<div class="bg-white rounded-lg shadow-sm p-6">
<h1 class="text-2xl font-bold mb-2">Order Confirmed!</h1>
<p class="text-gray-500 mb-6">Order #{{ $order->id }} placed on {{ $order->created_at->format('M d, Y H:i') }}</p>
{{-- Line items table --}}
<table class="w-full mb-6">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Product</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Price</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Quantity</th>
<th class="text-left px-6 py-3 text-sm font-medium text-gray-500">Subtotal</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($order->items as $item)
<tr>
<td class="px-6 py-4 font-medium">{{ $item->product->name }}</td>
<td class="px-6 py-4">${{ number_format($item->price, 2) }}</td>
<td class="px-6 py-4">{{ $item->quantity }}</td>
<td class="px-6 py-4">${{ number_format($item->price * $item->quantity, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
- Below that code, add the total bar, status indicator, and closing tags:
{{-- Order total and status --}}
<div class="flex justify-between items-center border-t pt-4">
<span class="text-lg font-bold">Total: ${{ number_format($order->total, 2) }}</span>
<span class="text-green-600 font-semibold">Status: {{ ucfirst($order->status) }}</span>
</div>
</div>
<a href="{{ route('products.index') }}" class="text-blue-600 hover:underline mt-4 inline-block">← Continue Shopping</a>
@endsection
- Save confirmation.blade.php.
Time to test the entire checkout flow. Before you run through it, think about this question: when you place an order, do you think the product stock levels on the catalog page will decrease?
- Return to http://localhost:8000/products in your browser.
- Pick any product and note its stock level.
- Click Add to Cart on that product.
- Click Proceed to Checkout on the cart page.
- Click Place Order.
You should see the order confirmation page with your order number, the products you ordered, their prices, quantities, and the total.
- Click Continue Shopping to return to the product catalog.
- Find the product you just ordered and check its stock level.
The stock level has not changed. You ordered the product, but the inventory stayed the same. That is a problem.
- Now try something worse. Click Add to Cart on any product.
- On the cart page, change the quantity to 100 and click Update.
- Click Proceed to Checkout, then click Place Order.
The order goes through with no error, even though no product has 100 units in stock. The checkout flow has two gaps: it does not validate stock levels, and it does not decrement inventory on purchase. You will fix both in the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Compare your files against the code below. For files over 30 lines, scroll up and confirm each chunk is present in order.
Here is the full Order.php model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
protected $fillable = [
'status',
'total',
];
protected $casts = [
'total' => 'decimal:2',
];
public function items()
{
return $this->hasMany(OrderItem::class);
}
}
Here is the full OrderItem.php model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OrderItem extends Model
{
use HasFactory;
protected $fillable = [
'order_id',
'product_id',
'quantity',
'price',
];
protected $casts = [
'price' => 'decimal:2',
'quantity' => 'integer',
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}
Here is the full routes/web.php:
<?php
use App\Http\Controllers\ProductController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->route('products.index');
});
Route::resource('products', ProductController::class)->only(['index', 'show']);
Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add/{product}', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update/{product}', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove/{product}', [CartController::class, 'remove'])->name('cart.remove');
Route::get('/checkout', [OrderController::class, 'checkout'])->name('checkout');
Route::post('/orders', [OrderController::class, 'store'])->name('orders.store');
Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');
For OrderController.php, checkout.blade.php, and confirmation.blade.php, scroll up to the chunks you pasted earlier and confirm each one is present in order.
Your checkout flow is working, but it is dangerously trusting. Next up, you will add inventory validation and database transactions to close the gaps you just discovered.
Fix Inventory with Validation
Your checkout flow works. Customers can add products, review a summary, and place an order. But you saw the flaw firsthand in the last step: customers can order 100 units of a product that only has 10 in stock, and the order goes through without complaint.
Successful orders also never reduce inventory, so every product stays at its original stock level forever. In this step, you'll fix both problems using a Form Request for stock validation and a database transaction for safe inventory updates.
In this step, get ready to:
- Create a CheckoutRequest Form Request that rejects over-stock orders.
- Update the OrderController to validate stock and wrap order processing in a database transaction.
- Verify that over-stock orders are rejected and valid orders decrement inventory.
Create the CheckoutRequest Form Request
Laravel's Form Request classes let you pull validation logic out of your controller and into a dedicated class. You'll create one that checks every cart item against the database before the order goes through.
- Generate the CheckoutRequest file by running this command in your terminal:
php artisan make:request CheckoutRequest
- Confirm the file was created by running:
ls app/Http/Requests/
You should see CheckoutRequest.php in the output.
Don't see the file?
Make sure you're running the command from the shopflow project folder. If you're in a different directory, run cd ~/Desktop/shopflow first.
Help me figure out why my artisan command didn't generate the file.
The generated file has authorize() returning false by default. You need to change it to true, add the Product import, and set up the class frame.
- Open app/Http/Requests/CheckoutRequest.php in VS Code and replace the entire file contents with this:
<?php
namespace App\Http\Requests;
use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
class CheckoutRequest extends FormRequest
{
// Allow any user to check out
public function authorize()
{
return true;
}
// No standard validation rules needed
public function rules()
{
return [];
}
}
What does this code do?
This sets up the skeleton of your Form Request. authorize() returns true so any visitor can submit the checkout form. rules() returns an empty array because you'll use custom validation logic instead of standard field rules.
The real validation lives in a withValidator() method. This method gives you access to the validator instance so you can add custom checks that run after the standard rules.
- In CheckoutRequest.php, find the closing } of the class and add the following method above it:
public function withValidator($validator)
{
$validator->after(function ($validator) {
$cart = session()->get('cart', []);
if (empty($cart)) {
$validator->errors()->add('cart', 'Your cart is empty.');
return;
}
// Load all cart products from the database
$products = Product::whereIn('id', array_keys($cart))->get();
foreach ($products as $product) {
$requested = $cart[$product->id]['quantity'];
// Reject if requested quantity exceeds stock
if ($requested > $product->stock) {
$validator->errors()->add(
'stock_' . $product->id,
$product->name . ' only has ' . $product->stock
. ' in stock, but you requested ' . $requested . '.'
);
}
}
});
}
What does withValidator do?
The after() callback runs after all standard rules pass. Inside it, you loop through every product in the session cart and load the current stock from the database.
If the cart quantity exceeds the product's stock, you add a specific validation error naming the product and the available amount. Laravel collects all these errors and redirects the user back to the checkout page.
- Save CheckoutRequest.php.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm both pieces are in place in CheckoutRequest.php:
- The class frame with the Product import, authorize() returning true, and rules() returning an empty array.
- The withValidator() method with the after() callback that loops through the cart and checks each product's stock.
Wire up validation and display errors
The CheckoutRequest exists, but the controller still uses the generic Request class. You need to swap the type-hint so Laravel runs your validation automatically when the checkout form is submitted.
- Open app/Http/Controllers/OrderController.php and find these import lines at the top:
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\Request;
- Replace the use Illuminate\Http\Request; line with use App\Http\Requests\CheckoutRequest; so the imports look like this:
use App\Http\Requests\CheckoutRequest;
use App\Models\Order;
use App\Models\Product;
- Now scroll down to the store method and find this line:
public function store(Request $request)
- Replace Request with CheckoutRequest so the line now reads:
public function store(CheckoutRequest $request)
- Save OrderController.php.
Why does changing the type-hint enable validation?
When Laravel sees a Form Request type-hint on a controller method, it automatically runs the Form Request's validation before the method body executes. If validation fails, Laravel redirects back with the error messages. You don't need to call any validation method yourself.
The controller now triggers validation, but the checkout page doesn't display errors yet. You need to add an error block to the Blade view.
- Open resources/views/orders/checkout.blade.php and find this line:
<h1 class="text-2xl font-bold mb-6">Checkout</h1>
- Add this error display block directly below the <h1> line, before the <div class="bg-white...">:
@if($errors->any())
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<ul class="list-disc list-inside">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
What does this code do?
Laravel automatically makes a $errors variable available in every Blade view. When validation fails, the errors from your CheckoutRequest are flashed to the session and appear here as a red error list above the order summary.
- Save checkout.blade.php.
Not sure where to paste the error block?
The @if($errors->any()) block goes between the <h1> heading and the first <div class="bg-white"> that wraps the table. Make sure it sits at the same indentation level as those elements.
Help me place the error block in my checkout Blade template.
Time to test the validation. Before you run this, think about what you expect to happen when you try to order more items than a product has in stock.
- Open http://localhost:8000/products in your browser.
- Pick any product and note its stock level (the number next to "Stock:").
- Click Add to Cart on that product.
- On the cart page, change the quantity to a number higher than the product's stock level (e.g. 100).
- Click Update to save the new quantity.
- Click Proceed to Checkout.
- Click Place Order.
You should see a red error box at the top of the checkout page naming the product and telling you how many are actually in stock. The order is blocked.
Why does the error name the specific product?
Your withValidator() callback builds a human-readable message per product, like "Wireless Headphones only has 15 in stock, but you requested 100." Generic messages like "invalid quantity" leave the customer guessing which product is the problem.
Add the database transaction and inventory decrement
Validation stops bad orders, but good orders still don't reduce stock. If two customers place orders at the same time, they could both buy the last item. A database transaction ensures that creating the order and decrementing stock happen as one atomic operation.
- In OrderController.php, add this import below the existing use statements at the top of the file:
use Illuminate\Support\Facades\DB;
- Now replace your entire store method (from public function store through its closing }) with this:
public function store(CheckoutRequest $request)
{
$cart = session()->get('cart', []);
$products = Product::whereIn('id', array_keys($cart))->get();
// Wrap order creation in a transaction for data integrity
$order = DB::transaction(function () use ($cart, $products) {
$total = 0;
foreach ($products as $product) {
$total += $product->price * $cart[$product->id]['quantity'];
}
$order = Order::create([
'status' => 'completed',
'total' => $total,
]);
foreach ($products as $product) {
$quantity = $cart[$product->id]['quantity'];
$order->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'price' => $product->price,
]);
// Atomically reduce stock by the ordered quantity
$product->decrement('stock', $quantity);
}
return $order;
});
session()->forget('cart');
return redirect()->route('orders.show', $order)
->with('success', 'Order placed successfully!');
}
What changed in the store method?
The entire order creation and inventory update is now wrapped in DB::transaction(). If anything fails inside the closure (a database error, a constraint violation), all changes are rolled back automatically. No half-created orders.
The key addition is $product->decrement('stock', $quantity) after each OrderItem is created. This is an Eloquent method that issues a single UPDATE query to reduce the stock column, avoiding race conditions where two requests could read the same stock value.
- Save OrderController.php.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before testing.
ⓧ I'd like to double check the full code
Scroll up and confirm all three changes are in place in OrderController.php:
- The CheckoutRequest import replaces the old Illuminate\Http\Request import.
- The use Illuminate\Support\Facades\DB; import is present.
- The store method uses DB::transaction() and calls $product->decrement('stock', $quantity) after each OrderItem.
Seeing a class not found error?
Make sure the import at the top of OrderController.php says use App\Http\Requests\CheckoutRequest; and that use Illuminate\Support\Facades\DB; is also present. A missing or misspelled import is the most common cause.
Help me debug a class not found error in my Laravel OrderController.
Now test the complete flow. Before you place this order, do you expect the stock levels on the products page to change this time?
- Go back to http://localhost:8000/cart in your browser.
- Change the quantity back to a number within the product's stock level (e.g. 2).
- Click Update.
- Visit http://localhost:8000/products and note the stock number for the product in your cart.
- Click Proceed to Checkout from the cart page.
- Click Place Order.
You should see the order confirmation page with your order details.
- Click Continue Shopping to return to the product catalog.
The stock level for the product you ordered has decreased by the quantity you ordered. The decrement() call inside the transaction updated the database, and you can see the result in the catalog.
Why a transaction and not just a regular save?
Without a transaction, the order could be created successfully but the stock decrement could fail (or vice versa). That leaves the database in an inconsistent state: an order exists but inventory wasn't updated.
With DB::transaction(), every database write inside the closure either succeeds together or fails together. If anything goes wrong, all changes are rolled back to the state before the transaction started.
Your storefront now has real inventory integrity. Customers can't order what isn't in stock, and every successful order accurately reduces available inventory. Next up, you'll take on a Secret Mission to make this catalog even more powerful with search and filtering.
Secret mission
Add Product Search and Category Filtering
Your 12-product catalog is small enough to browse by scrolling. A real store has hundreds of products. Can you add a search bar and category filter so customers can find what they need without scrolling through the entire grid?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The shopflow project folder on your Desktop (contains all application code, Blade views, migrations, and the SQLite database).
- The php artisan serve development server process running at http://localhost:8000.
Keep everything running
No action needed. Choose this if you are still actively building on your ShopFlow storefront or want to experiment with new features.
- Your dev server keeps running at http://localhost:8000. If you closed the terminal, restart it by opening a terminal in your shopflow folder and running php artisan serve.
- Your SQLite database at database/database.sqlite holds all your seeded products and any orders you placed.
- PHP, Composer, VS Code, and Node.js remain installed on your machine.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files and database stay on disk for when you return.
- Switch to the terminal where php artisan serve is running.
- Press Ctrl+C to stop the server.
- When you are ready to resume, open a terminal in your shopflow folder and run php artisan serve again.
Delete - I don't want to use this again
Remove all project files and stop the dev server. This is permanent.
- Switch to the terminal where php artisan serve is running and press Ctrl+C to stop it.
- Delete the entire project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf shopflow
Windows
cd ~\Desktop
rmdir /s /q shopflow
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see shopflow listed.
Nice Work!
Nice Work!
You just built ShopFlow, a full-stack Laravel 8 e-commerce storefront with a product catalog, session-based shopping cart, and order placement flow with real inventory tracking.
What you learned:
- Built a full-stack storefront using Eloquent models (Product, Order, OrderItem) with hasMany and belongsTo relationships, database migrations, a factory with realistic data, and a seeder to populate the catalog.
- Implemented a session-based shopping cart with add, update, and remove operations, plus a checkout flow that creates Order and OrderItem records and renders confirmation pages with Blade templates.
- Fixed inventory validation using a custom Form Request with withValidator() and after() callbacks, and wrapped order processing in a DB::transaction() for atomic stock decrement.
- Secret Mission: Added product search and category filtering with conditional query scoping using when() clauses on the ProductController.
Ready to quiz yourself?