E-Commerce Logic

Reviewed & published by Brayan K

By the end of this lesson you'll build the business logic behind a real online shop — a cart that handles money safely, correct discount-and-tax maths, and a checkout that creates an order and adjusts stock atomically, the way production systems actually do it.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ The Cart: Add, Update, Remove

A cart is a temporary collection of items before the customer commits to buying. It needs four jobs: add a product (and merge if it's already there), update a quantity, remove a line, and report the subtotal. The single most important decision is how you store the price. Use integer cents — store $29.99 as 2999 — and only divide by 100 when you print. (More on why in the next section; for now just notice the prices are whole numbers.) Keying the lines by product id means adding the same product twice merges into one line automatically.

<?php
// A shopping Cart. Golden rule: NEVER store money as a float.
// $29.99 is stored as the integer 2999 (cents). You divide by 100
// ONLY when you print it — never while calculating.

class Cart {
    // Each line is keyed by product id so duplicates merge automatically.
    // value = ['name' => string, 'price' => int(cents), 'qty' => int]
    private array $lines = [];

    // Add a product, or bump the quantity if it's already in the cart.
    public function add(int $id, string $name, int $priceCents, int $qty = 1): void {
        if (isset($this->lines[$id])) {
            $this->lines[$id]['qty'] += $qty;          // merge: just add to qty
        } else {
            $this->lines[$id] = ['name' => $name, 'price' => $priceCents, 'qty' => $qty];
        }
    }

    // Change a line to an exact quantity. qty <= 0 removes the line entirely.
    public function update(int $id, int $qty): void {
        if (!isset($this->lines[$id])) return;          // nothing to update
        if ($qty <= 0) { unset($this->lines[$id]); return; }
        $this->lines[$id]['qty'] = $qty;
    }

    public function remove(int $id): void {
        unset($this->lines[$id]);                       // drop the line if present
    }

    // Subtotal = sum of (price x qty) for every line, all in cents.
    public function subtotal(): int {
        $sum = 0;
        foreach ($this->lines as $line) {
            $sum += $line['price'] * $line['qty'];
        }
        return $sum;                                    // an int — still cents
    }

    public function lines(): array { return $this->lines; }
}

$cart = new Cart();
$cart->add(1, "Wireless Mouse", 2999);        // one mouse  @ $29.99
$cart->add(2, "Mechanical Keyboard", 8999);   // one board  @ $89.99
$cart->add(3, "USB-C Hub", 3499, 2);          // two hubs   @ $34.99
$cart->add(1, "Wireless Mouse", 2999);        // same id -> merges to qty 2
$cart->update(2, 3);                          // change keyboards to qty 3
$cart->remove(99);                            // id not in cart -> no-op

foreach ($cart->lines() as $id => $line) {
    // money_format: cents -> "$xx.xx". sprintf keeps columns lined up.
    printf("  #%d  %-20s x%d  $%.2f\n",
        $id, $line['name'], $line['qty'], $line['price'] * $line['qty'] / 100);
}
printf("  Subtotal: $%.2f\n", $cart->subtotal() / 100);   // 17996 -> $179.96

Notice how update(2, 3) set the keyboard line to exactly 3, and adding mouse id 1 twice produced a single line of quantity 2. remove(99) on a product that isn't in the cart simply does nothing — defensive code that won't crash on bad input.

2️⃣ Discounts, Coupons & Tax

Here is the rule that catches everyone: discount first, then tax. A coupon comes off the subtotal, and tax is charged on what's left (the discounted amount). Round to a whole cent once per step — not on every line — or tiny errors accumulate. The two helpers below model the two coupon kinds you'll meet: a percent coupon (15% off) and a flat coupon ($10 off), capped so it can never make the total negative.

<?php
// Discounts, coupons and tax. The ORDER is the whole ballgame:
//   1. subtotal      (sum of the lines)
//   2. discount      (coupon comes off the subtotal)
//   3. tax           (charged on the DISCOUNTED amount, in most regions)
//   4. total         (discounted subtotal + tax)
// Get the order wrong and every receipt is a penny or two off forever.

// A coupon: percent off, OR a flat amount off (in cents) — never both.
function discountCents(int $subtotal, ?array $coupon): int {
    if ($coupon === null) return 0;
    if ($coupon['type'] === 'percent') {
        // round ONCE, here — not on each line — to avoid drift.
        return (int) round($subtotal * $coupon['value'] / 100);
    }
    // 'flat' coupon: never discount more than the subtotal.
    return min($coupon['value'], $subtotal);
}

// Tax is a percentage of whatever is left AFTER the discount.
function taxCents(int $taxableBase, float $taxPercent): int {
    return (int) round($taxableBase * $taxPercent / 100);
}

$subtotal = 39993;                                  // $399.93 from the cart
$coupon   = ['type' => 'percent', 'value' => 15];   // SAVE15 = 15% off

$discount = discountCents($subtotal, $coupon);      // round(39993*0.15) = 5999
$taxable  = $subtotal - $discount;                  // 33994  (tax base)
$tax      = taxCents($taxable, 8.0);                // round(33994*0.08) = 2720
$total    = $taxable + $tax;                        // 36714  -> $367.14

printf("  Subtotal:  $%7.2f\n", $subtotal / 100);
printf("  Discount: -$%7.2f\n", $discount / 100);
printf("  Taxable:   $%7.2f\n", $taxable  / 100);
printf("  Tax (8%%):  $%7.2f\n", $tax      / 100);   // %% prints one literal %
printf("  --------------------\n");
printf("  TOTAL:     $%7.2f\n", $total    / 100);

Each money step does its own single round() back to an integer. The %% in printf prints one literal % sign — handy when your label contains a percentage.

3️⃣ Checkout: One Transaction, No Overselling

Checkout is where money and stock both move, so it must be all-or-nothing. A transaction gives you that: beginTransaction() opens it, commit() makes every write permanent together, and rollBack() undoes the lot if any step throws. The clever bit is the stock decrement: UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?. That stock >= ? guard means if two shoppers race for the last unit, the database lets only one update match — the other changes zero rows, and you treat that as out-of-stock.

<?php
// Checkout is the dangerous part: money changes hands and stock moves.
// It must be ALL-OR-NOTHING. A transaction guarantees that: every write
// commits together, or — if anything fails — they all roll back as if
// nothing happened. No half-made orders, no stock vanishing into thin air.

function checkout(PDO $db, int $userId, array $cart, int $totalCents): int {
    $db->beginTransaction();                 // open the transaction
    try {
        // 1) Create the order row. Status starts as 'pending'.
        $stmt = $db->prepare(
            "INSERT INTO orders (user_id, total_cents, status)
             VALUES (?, ?, 'pending')"
        );
        $stmt->execute([$userId, $totalCents]);
        $orderId = (int) $db->lastInsertId();

        // 2) For each line: decrement stock SAFELY, then record the line.
        $dec = $db->prepare(
            // The "AND stock >= ?" is the race-condition guard. If two
            // shoppers race for the last unit, only ONE update matches.
            "UPDATE products SET stock = stock - ?
             WHERE id = ? AND stock >= ?"
        );
        $line = $db->prepare(
            "INSERT INTO order_items (order_id, product_id, qty, price_cents)
             VALUES (?, ?, ?, ?)"
        );

        foreach ($cart as $id => $item) {
            $dec->execute([$item['qty'], $id, $item['qty']]);
            if ($dec->rowCount() === 0) {
                // 0 rows changed = not enough stock. Bail out.
                throw new RuntimeException("Out of stock: {$item['name']}");
            }
            $line->execute([$orderId, $id, $item['qty'], $item['price']]);
        }

        // 3) Everything worked — mark paid and COMMIT all of it at once.
        $db->prepare("UPDATE orders SET status = 'paid' WHERE id = ?")
           ->execute([$orderId]);
        $db->commit();                       // make every change permanent
        return $orderId;

    } catch (Throwable $e) {
        $db->rollBack();                     // undo EVERYTHING — clean slate
        throw $e;                            // let the caller show the error
    }
}

// (Sketch of the call — needs a real $db connection to run.)
// $orderId = checkout($db, 42, $cart, 36714);
echo "checkout() wraps the order, stock and line items in one transaction.\n";
echo "If any product is out of stock, rollBack() undoes the whole order.\n";

The order is created as pending, every line is written, stock is decremented with the race guard, and only then is the order flipped to paid and committed. If anything fails — a sold-out item, a dropped connection — rollBack() leaves the database exactly as it was. That is the difference between a real shop and a toy one.

4️⃣ Your Turn: Get the Money Right

Time to do it yourself. The script below computes a total but the three money steps have blanks. Fill in each ___ using the 👉 hint, keeping the discount → taxable → tax order, then run it and check the Output panel.

<?php
// 🎯 YOUR TURN — finish the checkout maths. Fill each ___ , then run it.
// Remember the order: subtotal -> discount -> tax-on-discounted -> total.
// Everything is in cents (integers). Divide by 100 only to print.

$subtotal = 12000;                 // $120.00 worth of goods
$couponPercent = 25;               // a 25%-off coupon
$taxPercent = 10.0;                // 10% sales tax

// 1) Discount = 25% of the subtotal, rounded to a whole cent.
$discount = (int) round($subtotal * ___ / 100);   // 👉 replace ___ with $couponPercent

// 2) Tax base is the subtotal with the discount taken off.
$taxable = $subtotal - ___;                        // 👉 replace ___ with $discount

// 3) Tax = 10% of the TAXABLE amount (not the subtotal!).
$tax = (int) round(___ * $taxPercent / 100);       // 👉 replace ___ with $taxable

// 4) Total = taxable + tax.
$total = $taxable + $tax;

printf("Total: $%.2f\n", $total / 100);

// ✅ Expected output:
//    Total: $99.00
//    (discount 3000, taxable 9000, tax 900, total 9900 cents)

One more. This one is the stock guard — the single comparison that stops you overselling. Replace the ___ so a sale only goes through when there's enough on hand.

<?php
// 🎯 YOUR TURN — make stock decrement SAFE against overselling.
// In-memory products. Decrement only if there's enough on hand.

$products = [
    1 => ["name" => "Wireless Mouse", "stock" => 2],
    7 => ["name" => "Rare Vinyl",     "stock" => 1],
];

function buy(array &$products, int $id, int $qty): void {
    $have = $products[$id]['stock'];

    // 1) Only sell when there is ENOUGH stock to cover the order.
    if ($have ___ $qty) {                           // 👉 replace ___ with  >=
        $products[$id]['stock'] -= $qty;            // take it out of stock
        echo "OK: sold {$qty} x {$products[$id]['name']}\n";
    } else {
        // 2) Not enough — refuse the sale, leave stock untouched.
        echo "FAIL: only {$have} left of {$products[$id]['name']}\n";
    }
}

buy($products, 1, 2);     // 2 in stock, want 2 -> OK, stock now 0
buy($products, 7, 3);     // 1 in stock, want 3 -> FAIL, stock stays 1
buy($products, 7, 1);     // 1 in stock, want 1 -> OK, stock now 0

// ✅ Expected output:
//    OK: sold 2 x Wireless Mouse
//    FAIL: only 1 left of Rare Vinyl
//    OK: sold 1 x Rare Vinyl

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — E-Commerce Logic

ConceptIn codeWhy it matters
Money as cents$priceCents = 2999;Integers stay exact; floats drift
Display money$cents / 100Divide by 100 only to print
Discountround($sub * $pct / 100)Comes off the subtotal first
Taxround($taxable * $rate / 100)On the discounted amount
TransactionbeginTransaction / commitAll-or-nothing checkout
Stock guardWHERE stock >= ?Atomic — prevents overselling
Order statespending → paid → shippedDrives what can happen next

Mini-Challenge: Order State Machine

No code is filled in this time — just a brief and an outline. Build the state machine yourself, run it on onecompiler.com/php or your own machine, then check it against the expected output in the comments. Enforcing legal transitions is exactly how real order systems stop an order jumping from "pending" straight to "delivered".

<?php
// 🎯 MINI-CHALLENGE: an order state machine.
// No code is filled in — work from the steps below, then run it.
//
// An order moves through states in ONE legal direction:
//   pending -> paid -> processing -> shipped -> delivered
// (and from anything-not-yet-shipped it may go to 'cancelled').
//
// 1. Make an array $flow that maps each state to the states it may move to.
//    e.g. 'paid' => ['processing', 'cancelled']
// 2. Write transition(string $from, string $to): bool that returns true
//    only when $to is in $flow[$from].
// 3. Print a line for each attempt, e.g.:
//    transition('pending','paid')      -> "pending -> paid: OK"
//    transition('shipped','pending')   -> "shipped -> pending: BLOCKED"
//
// Tip: in_array($to, $flow[$from] ?? []) tells you if a hop is legal.
//
// ✅ Expected output (example):
//    pending -> paid: OK
//    paid -> shipped: BLOCKED
//    paid -> processing: OK
//    shipped -> pending: BLOCKED

// your code here

🎉 Lesson Complete!

Practice quiz

Why store money as integer cents instead of a float like 29.99?

  • Floats take more memory
  • Integers are easier to type
  • Floats drift (0.1 + 0.2 isn't exactly 0.3); integers stay exact
  • Floats can't be negative

Answer: Floats drift (0.1 + 0.2 isn't exactly 0.3); integers stay exact. Floats can't represent most decimals exactly, so errors compound across a cart. Store $29.99 as 2999 and divide by 100 only to display.

How is $29.99 stored as integer cents?

  • 2999
  • 29.99
  • 30
  • 299

Answer: 2999. Multiply by 100: $29.99 becomes the integer 2999 cents. You divide by 100 only when formatting for display.

In the lesson's Cart, what happens when you add the same product id twice?

  • A duplicate line is created
  • The second add is ignored
  • An error is thrown
  • The two are merged into one line and the quantity increases

Answer: The two are merged into one line and the quantity increases. Lines are keyed by product id, so adding the same id again just bumps that line's qty (e.g. add mouse twice -> qty 2).

What is the correct order for discount and tax?

  • Tax first, then apply the discount
  • Discount first, then tax the discounted amount
  • Apply both to the original subtotal independently
  • Tax and discount can be done in any order

Answer: Discount first, then tax the discounted amount. In most regions the coupon comes off the subtotal, then tax is charged on what's left. Wrong order makes every receipt a few cents off.

Subtotal 12000c, a 25% coupon, then 10% tax — what is the total?

  • $99.00
  • $108.00
  • $90.00
  • $99.90

Answer: $99.00. Discount = 25% of 12000 = 3000; taxable = 9000; tax = 10% of 9000 = 900; total = 9900c = $99.00.

How should you round money during a multi-step calculation?

  • Round on every single line
  • Never round until the very end of everything
  • Round once per step (e.g. once for discount, once for tax)
  • Round each digit separately

Answer: Round once per step (e.g. once for discount, once for tax). Each money step does its own single round() back to an integer; rounding on every line lets tiny errors accumulate.

Why must checkout run inside a database transaction?

  • Transactions make queries faster
  • The order, line items, and stock changes must all commit together or all roll back
  • It hashes the payment
  • It is required to connect to MySQL

Answer: The order, line items, and stock changes must all commit together or all roll back. Checkout writes rows that only make sense together; a transaction is all-or-nothing so a crash never leaves a half-built order.

Which SQL guard prevents two shoppers overselling the last unit?

  • SELECT stock FROM products WHERE id = ?
  • DELETE FROM products WHERE stock = 0
  • UPDATE products SET stock = 0
  • UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?

Answer: UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?. The atomic 'AND stock >= ?' guard means only one racing request matches and changes a row; the other gets 0 rows and is treated as out of stock.

After the guarded stock UPDATE, what does rowCount() === 0 mean?

  • The sale succeeded
  • Not enough stock — bail out and roll back
  • The product was deleted
  • The connection dropped

Answer: Not enough stock — bail out and roll back. Zero rows changed means the stock >= qty condition failed, so the item is out of stock and you throw to trigger the rollback.

Why model order states as a small state machine?

  • To make the database smaller
  • To avoid using a status column
  • To enforce legal transitions so an order can't jump from pending straight to delivered
  • To speed up checkout

Answer: To enforce legal transitions so an order can't jump from pending straight to delivered. Orders move pending -> paid -> processing -> shipped -> delivered; mapping each state to its allowed next states blocks illegal hops.

Continue this course

Frequently asked questions

Why store money as integer cents instead of a float like 29.99?

Because floats can't represent most decimal fractions exactly, so arithmetic drifts: in PHP, 0.1 + 0.2 is 0.30000000000000004, and rounding errors pile up across a cart of dozens of items. Storing $29.99 as the integer 2999 (cents) makes every add, multiply and subtract exact. You divide by 100 only at the very end, when you format the number for display. For currencies with three decimal places (like Bahraini dinar) use thousandths; the principle — smallest indivisible unit as an integer — is the same.

Should tax be calculated before or after the discount?

In most jurisdictions tax is charged on the discounted amount — the coupon comes off first, then tax applies to what's left. So the order is subtotal, then subtract the discount, then apply tax to that reduced base, then add tax to get the total. Getting the order wrong throws every receipt off by a few cents and can cause real accounting and compliance problems. Tax rules vary by region, so for production always confirm the local rule (and consider a tax service like TaxJar or Avalara) rather than hard-coding one percentage.

What stops two shoppers from buying the last item at the same time?

A race condition: both requests read "1 in stock", both think they can sell it, and you oversell. The fix lives in the database. Wrap the order in a transaction, then decrement with a guard like UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?. Because the row update is atomic, only one of the two racing requests will match the condition and change a row — the other gets zero rows affected, which you treat as out-of-stock and roll back. SELECT ... FOR UPDATE inside the transaction achieves the same by locking the row while you decide.

Why does checkout need a database transaction?

Checkout writes several rows that only make sense together: the order, each order line, and the stock decrement on each product. A transaction makes them all-or-nothing — beginTransaction() opens it, commit() makes every change permanent at once, and rollBack() undoes everything if any step fails. Without it, a crash halfway through leaves a half-built order, or stock that dropped for an order that never completed. The rule of thumb: anything that must be true together should be written inside one transaction.

What are order states and why track a status timeline?

An order moves through a lifecycle — typically pending, paid, processing, shipped, delivered, with cancelled or refunded as exits. The current status drives what the system and the customer can do next (you can't ship an unpaid order). Recording each change with a timestamp gives you an audit trail for support and accounting, powers the customer's order-tracking page, and lets you enforce legal transitions so an order can't jump from pending straight to delivered. Model the allowed moves explicitly — a small state machine — rather than letting any status be set at any time.

Related lessons