Project: Shopping Cart with Sessions

JA JASWANT Updated 04 Nov 2026
9 min read ·Lesson 237 of 238

What You Will Build

A shopping cart for the shop: visitors add products, change quantities, remove items, see the subtotal, shipping, and total, and check out to create an order. The cart is stored in the session, while prices and stock always come from the database. Checkout runs in a transaction that locks the products, so stock can never be oversold.

Features

FeatureTechniqueLesson
Cart stored per visitor$_SESSIONPHP Sessions
Prices that cannot be manipulatedOnly product IDs and quantities stored; prices loaded from the databaseSecure Cookies
Exact money calculationsInteger paisePHP Floats
Availability checksStock and active status validated on every view—
Safe checkoutTransaction with SELECT ... FOR UPDATEPDO Transactions
Protected actionsPOST + CSRF tokensCSRF Protection

Project Structure

shop-cart/
├── public/
│   ├── products.php      ← product list with "Add to cart" buttons
│   ├── cart.php          ← cart view with quantity updates
│   ├── cart-action.php   ← POST: add, update, remove
│   └── checkout.php      ← POST: create the order
└── src/
    ├── bootstrap.php     ← session, e(), csrf, flash(), pdo()
    └── cart.php          ← Cart class, pricing, checkout

The project uses the products, orders, and order_items tables from MySQL Create Table.


Step 1: The Cart Class

The cart stores only product IDs and quantities. Names, prices, and stock are looked up whenever the cart is displayed or checked out.

<?php
// src/cart.php
final class Cart
{
    private const MAX_QUANTITY = 99;
    private array $items;
    public function __construct(array &$storage)
    {
        $storage['cart'] ??= [];
        $this->items = &$storage['cart'];   // changes go straight into the session array
    }
    public function add(int $productId, int $quantity = 1): void
    {
        if ($quantity < 1) {
            throw new InvalidArgumentException('Quantity must be at least 1.');
        }
        $this->items[$productId] = min(self::MAX_QUANTITY, ($this->items[$productId] ?? 0) + $quantity);
    }
    public function update(int $productId, int $quantity): void
    {
        if ($quantity <= 0) {
            $this->remove($productId);
            return;
        }
        $this->items[$productId] = min(self::MAX_QUANTITY, $quantity);
    }
    public function remove(int $productId): void
    {
        unset($this->items[$productId]);
    }
    public function clear(): void
    {
        $this->items = [];
    }
    public function items(): array
    {
        return $this->items;
    }
    public function count(): int
    {
        return array_sum($this->items);
    }
}

Why It Is Designed This Way

  • The constructor receives the session array by reference and binds its cart entry to the private property. Every change made through the class is automatically saved in the session — and in tests, a plain array can be passed instead of $_SESSION.
  • Quantities are limited to sensible values (1–99); zero or negative quantities remove the item.
  • The class knows nothing about prices, so a manipulated request cannot set a price: there is no place to store one.

Step 2: Pricing the Cart

<?php
// src/cart.php (continued)
const FREE_SHIPPING_FROM_PAISE = 99_900;   // ₹999
const SHIPPING_PAISE = 4_900;              // ₹49
function toPaise(string $price): int
{
    return (int) round((float) $price * 100);
}
function money(int $paise): string
{
    return '₹' . number_format($paise / 100, 2);
}
function priceCart(array $items, array $products): array
{
    $lines = [];
    $notices = [];
    $unavailable = [];
    $subtotal = 0;
    foreach ($items as $productId => $quantity) {
        $product = $products[$productId] ?? null;
        if ($product === null || !$product['is_active'] || $product['stock'] < 1) {
            $notices[] = ($product['name'] ?? 'A product') . ' is no longer available.';
            $unavailable[] = $productId;
            continue;
        }
        if ($quantity > $product['stock']) {
            $quantity = $product['stock'];
            $notices[] = "Only {$product['stock']} × {$product['name']} available; quantity adjusted.";
        }
        $lineTotal = toPaise($product['price']) * $quantity;
        $subtotal += $lineTotal;
        $lines[] = ['id' => $productId, 'name' => $product['name'], 'quantity' => $quantity, 'total' => $lineTotal];
    }
    $shipping = ($subtotal === 0 || $subtotal >= FREE_SHIPPING_FROM_PAISE) ? 0 : SHIPPING_PAISE;
    return compact('lines', 'notices', 'unavailable', 'subtotal', 'shipping') + ['total' => $subtotal + $shipping];
}
function loadProducts(PDO $pdo, array $ids): array
{
    if ($ids === []) {
        return [];
    }
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $stmt = $pdo->prepare("SELECT id, name, price, stock, is_active FROM products WHERE id IN ($placeholders)");
    $stmt->execute(array_values($ids));
    return $stmt->fetchAll(PDO::FETCH_UNIQUE);
}

Testing the Cart Without a Database

<?php
require __DIR__ . '/src/cart.php';
$products = [
    1 => ['name' => 'Gel Pen', 'price' => '20.00', 'stock' => 118, 'is_active' => 1],
    3 => ['name' => 'Desk Lamp', 'price' => '1299.00', 'stock' => 6, 'is_active' => 1],
    4 => ['name' => 'Backpack', 'price' => '899.00', 'stock' => 0, 'is_active' => 1],
];
function printSummary(array $summary): void
{
    foreach ($summary['lines'] as $line) {
        echo "{$line['name']} × {$line['quantity']} = " . money($line['total']) . "\n";
    }
    foreach ($summary['notices'] as $notice) {
        echo "Note: $notice\n";
    }
    echo 'Subtotal: ' . money($summary['subtotal'])
        . ' | Shipping: ' . ($summary['shipping'] === 0 ? 'FREE' : money($summary['shipping']))
        . ' | Total: ' . money($summary['total']) . "\n\n";
}
$session = [];
$cart = new Cart($session);
$cart->add(1, 3);
$cart->add(3);
$cart->add(4);
$summary = priceCart($cart->items(), $products);
printSummary($summary);
foreach ($summary['unavailable'] as $productId) {
    $cart->remove($productId);
}
$cart->update(3, 0);   // the customer removes the lamp
printSummary(priceCart($cart->items(), $products));
echo "Items in session: " . json_encode($session) . "\n";

Output

Gel Pen × 3 = ₹60.00
Desk Lamp × 1 = ₹1,299.00
Note: Backpack is no longer available.
Subtotal: ₹1,359.00 | Shipping: FREE | Total: ₹1,359.00
Gel Pen × 3 = ₹60.00
Subtotal: ₹60.00 | Shipping: ₹49.00 | Total: ₹109.00
Items in session: {"cart":{"1":3}}

How It Works

  1. All money is calculated in integer paise (₹20.00 → 2000), so there are no floating-point rounding errors (see PHP Floats).
  2. Products that are inactive or out of stock are reported and listed in unavailable, so the page can remove them from the cart.
  3. Quantities larger than the available stock are reduced with a notice.
  4. Shipping is free from ₹999; the total is recalculated every time — nothing is stored except IDs and quantities.
  5. The final line shows that the session contains only {product ID: quantity}.

Step 3: Cart Actions

<?php
// public/cart-action.php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/cart.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || !csrfValid($_POST['_csrf'] ?? null)) {
    http_response_code(405);
    exit('Invalid request.');
}
$cart = new Cart($_SESSION);
$productId = filter_input(INPUT_POST, 'product_id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$quantity = filter_input(INPUT_POST, 'quantity', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99]]);
$action = $_POST['action'] ?? '';
if ($productId === false || $productId === null) {
    flash('Unknown product.');
} else {
    match ($action) {
        'add' => $cart->add($productId, $quantity ?: 1),
        'update' => $cart->update($productId, $quantity === false || $quantity === null ? 0 : $quantity),
        'remove' => $cart->remove($productId),
        default => null,
    };
    flash('Your cart was updated.');
}
header('Location: /cart.php', true, 303);
exit;

Every change is a POST request with a CSRF token, and all numbers are validated as integers within range. Adding a product that does not exist only stores an ID — it is reported and removed the next time the cart is priced.


Step 4: The Cart Page

<?php
// public/cart.php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/cart.php';
$cart = new Cart($_SESSION);
$summary = priceCart($cart->items(), loadProducts(pdo(), array_keys($cart->items())));
foreach ($summary['unavailable'] as $productId) {
    $cart->remove($productId);
}
$message = flash();
?>
<h1>Your cart (<?= $cart->count() ?> items)</h1>
<?php if ($message !== null): ?><p class="info"><?= e($message) ?></p><?php endif; ?>
<?php foreach ($summary['notices'] as $notice): ?><p class="warning"><?= e($notice) ?></p><?php endforeach; ?>
<?php if ($summary['lines'] === []): ?>
    <p>Your cart is empty. <a href="/products.php">Continue shopping</a></p>
<?php else: ?>
    <table>
        <?php foreach ($summary['lines'] as $line): ?>
            <tr>
                <td><?= e($line['name']) ?></td>
                <td>
                    <form method="post" action="/cart-action.php">
                        <input type="hidden" name="_csrf" value="<?= e(csrfToken()) ?>">
                        <input type="hidden" name="action" value="update">
                        <input type="hidden" name="product_id" value="<?= (int) $line['id'] ?>">
                        <input type="number" name="quantity" value="<?= (int) $line['quantity'] ?>" min="0" max="99">
                        <button type="submit">Update</button>
                    </form>
                </td>
                <td><?= e(money($line['total'])) ?></td>
            </tr>
        <?php endforeach; ?>
    </table>
    <p>Subtotal: <?= e(money($summary['subtotal'])) ?></p>
    <p>Shipping: <?= $summary['shipping'] === 0 ? 'FREE' : e(money($summary['shipping'])) ?></p>
    <p><strong>Total: <?= e(money($summary['total'])) ?></strong></p>
    <form method="post" action="/checkout.php">
        <input type="hidden" name="_csrf" value="<?= e(csrfToken()) ?>">
        <button type="submit">Place order</button>
    </form>
<?php endif; ?>

Step 5: Checkout in a Transaction

<?php
// src/cart.php (continued)
final class CheckoutException extends RuntimeException {}
function checkout(PDO $pdo, Cart $cart, int $customerId): int
{
    $items = $cart->items();
    if ($items === []) {
        throw new CheckoutException('Your cart is empty.');
    }
    ksort($items);   // lock rows in a consistent order to reduce deadlocks
    $pdo->beginTransaction();
    try {
        $placeholders = implode(',', array_fill(0, count($items), '?'));
        $stmt = $pdo->prepare("SELECT id, name, price, stock, is_active FROM products WHERE id IN ($placeholders) FOR UPDATE");
        $stmt->execute(array_keys($items));
        $products = $stmt->fetchAll(PDO::FETCH_UNIQUE);
        $summary = priceCart($items, $products);
        if ($summary['notices'] !== []) {
            throw new CheckoutException('Your cart changed: ' . implode(' ', $summary['notices']));
        }
        $pdo->prepare('INSERT INTO orders (customer_id, status, total) VALUES (?, ?, ?)')
            ->execute([$customerId, 'pending', number_format($summary['total'] / 100, 2, '.', '')]);
        $orderId = (int) $pdo->lastInsertId();
        $insertItem = $pdo->prepare('INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)');
        $reduceStock = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ?');
        foreach ($summary['lines'] as $line) {
            $insertItem->execute([$orderId, $line['id'], $line['quantity'], $products[$line['id']]['price']]);
            $reduceStock->execute([$line['quantity'], $line['id']]);
        }
        $pdo->commit();
        $cart->clear();
        return $orderId;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

public/checkout.php checks the CSRF token, calls requireLogin() to get the customer (from Project: Login & Registration System), runs checkout(), and redirects to an order confirmation page — or back to the cart with the CheckoutException message.

Why Checkout Is Safe

  1. SELECT ... FOR UPDATE locks the products until the transaction ends; two customers buying the last lamp are processed one after another.
  2. The cart is re-priced inside the transaction with locked, current data. If anything changed (price, stock, availability), checkout stops with a clear message instead of silently creating a different order.
  3. Each order item stores the unit price at the time of purchase.
  4. Any failure rolls back everything: no order without items, no stock reduction without an order.
  5. The cart is cleared only after a successful commit.

Testing the Project

TestExpected Result
Add the same product twiceQuantity increases instead of a second line
Set quantity to 0The item is removed
Change product_id in the form to 99999Removed with "A product is no longer available." when the cart is shown
Try to send a price in the formIgnored — the cart has no price field
Order more units than are in stockQuantity adjusted with a notice
Subtotal ₹999 or moreFree shipping
Two browsers buy the last unit simultaneouslyOne order succeeds; the other receives "Your cart changed…"
Submit cart-action.php without a CSRF token"Invalid request."

Common Mistakes

Storing Prices in the Cart or Form

Effect: Customers change prices with developer tools. Fix: Store only IDs and quantities; load prices from the database.

Float Arithmetic for Money

Effect: Totals like ₹59.999999. Fix: Calculate in integer paise.

Checking Stock Only When Adding to the Cart

Effect: Overselling when stock changes before checkout. Fix: Recheck and lock stock inside the checkout transaction.

Effect: Forged links change carts. Fix: Use POST with CSRF tokens.


Best Practices

  • Keep the session cart minimal: product IDs and quantities only.
  • Recalculate prices, availability, and totals from the database on every view.
  • Use integer minor units for all money calculations.
  • Create orders in a transaction with row locks and re-validation.
  • Protect every cart action with POST and CSRF tokens. Official reference: https://www.php.net/manual/en/book.session.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else User-defined Functions Slicing Strings Function Arguments PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System Project: CRUD App with MySQL Project: Shopping Cart with Sessions Project: REST API with PHP
Ask about this post
AI Ask about this post

Ask questions about Project: Shopping Cart with Sessions and get answers drawn from it.

Signed-in readers only.