Project: Shopping Cart with Sessions
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
| Feature | Technique | Lesson |
|---|---|---|
| Cart stored per visitor | $_SESSION | PHP Sessions |
| Prices that cannot be manipulated | Only product IDs and quantities stored; prices loaded from the database | Secure Cookies |
| Exact money calculations | Integer paise | PHP Floats |
| Availability checks | Stock and active status validated on every view | — |
| Safe checkout | Transaction with SELECT ... FOR UPDATE | PDO Transactions |
| Protected actions | POST + CSRF tokens | CSRF 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
cartentry 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
- All money is calculated in integer paise (₹20.00 → 2000), so there are no floating-point rounding errors (see PHP Floats).
- Products that are inactive or out of stock are reported and listed in
unavailable, so the page can remove them from the cart. - Quantities larger than the available stock are reduced with a notice.
- Shipping is free from ₹999; the total is recalculated every time — nothing is stored except IDs and quantities.
- 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
SELECT ... FOR UPDATElocks the products until the transaction ends; two customers buying the last lamp are processed one after another.- 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.
- Each order item stores the unit price at the time of purchase.
- Any failure rolls back everything: no order without items, no stock reduction without an order.
- The cart is cleared only after a successful commit.
Testing the Project
| Test | Expected Result |
|---|---|
| Add the same product twice | Quantity increases instead of a second line |
| Set quantity to 0 | The item is removed |
Change product_id in the form to 99999 | Removed with "A product is no longer available." when the cart is shown |
| Try to send a price in the form | Ignored — the cart has no price field |
| Order more units than are in stock | Quantity adjusted with a notice |
| Subtotal ₹999 or more | Free shipping |
| Two browsers buy the last unit simultaneously | One 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.
Cart Changes via GET Links
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