Skip to main content
    Courses/PHP/E-Commerce Logic

    Lesson 39 • Advanced

    E-Commerce Logic 🛒

    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.

    What You'll Learn in This Lesson

    • Build a Cart class that adds, updates, removes and merges items
    • Handle money safely as integer cents — never as floats
    • Apply coupons (percent and flat) and tax in the correct order
    • Write a checkout that creates an order inside a DB transaction
    • Decrement stock safely so two shoppers can't oversell the last item
    • Model order states (pending → paid → shipped → delivered)

    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.

    A Cart class — money kept as integer cents
    <?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
    Output
      #1  Wireless Mouse       x2  $59.98
      #2  Mechanical Keyboard  x3  $269.97
      #3  USB-C Hub            x2  $69.98
      Subtotal: $399.93
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Discount, then tax — in the right order
    <?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);
    Output
      Subtotal:  $ 399.93
      Discount: -$  59.99
      Taxable:   $ 339.94
      Tax (8%):  $  27.20
      --------------------
      TOTAL:     $ 367.14
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    checkout(): order + stock in a single transaction
    <?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";
    Output
    checkout() wraps the order, stock and line items in one transaction.
    If any product is out of stock, rollBack() undoes the whole order.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: finish the checkout maths
    <?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)
    Output
    Total: $99.00
    Fill the three ___ blanks with $couponPercent, $discount and $taxable. Your total should print $99.00.

    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.

    🎯 Your turn: stop overselling
    <?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
    Output
    OK: sold 2 x Wireless Mouse
    FAIL: only 1 left of Rare Vinyl
    OK: sold 1 x Rare Vinyl
    Replace the ___ with >= so a sale only happens when stock covers the quantity. Two sales should succeed and one should fail.

    Common Errors (and the fix)

    • Totals are off by a cent or two — you stored money as a float. 0.1 + 0.2 is 0.30000000000000004 in PHP, and the error compounds across a basket. Store every price as an integer number of cents and divide by 100 only when you print.
    • Two customers bought the same last item — a race condition. You checked stock, then decremented in a separate step, and another request slipped in between. Do it in one atomic UPDATE with WHERE stock >= ? (or SELECT ... FOR UPDATE) inside a transaction.
    • Stock dropped but the order never saved (or vice versa) — you wrote the rows without a transaction. A crash halfway leaves the data inconsistent. Wrap the whole checkout in beginTransaction()commit(), with rollBack() in a catch.
    • The tax total disagrees with your accountant — you applied tax before the discount, or rounded on every line. Apply the coupon to the subtotal first, then tax the discounted amount, rounding once per step.
    • "There is already an active transaction" — you called beginTransaction() twice without a commit() or rollBack() in between. Every transaction must be closed before the next one opens.

    Pro Tips

    • 💡 Persist the cart in the database, not just the session — a logged-in user's cart then follows them from laptop to phone.
    • 💡 Make payment idempotent. Send a unique key with the charge so a retried request never bills the customer twice.
    • 💡 Snapshot the price onto the order line. Store price_cents at purchase time so a later price change never alters an old invoice.
    • 💡 Model states as a tiny state machine — list the legal next states for each status and reject anything else, rather than letting any status be set at will.

    📋 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

    Frequently Asked Questions

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    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".

    🎯 Mini-Challenge: only allow legal status changes
    <?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
    Map each state to its allowed next states, write transition(), and print OK / BLOCKED for each attempt. Illegal hops must be blocked.

    🎉 Lesson Complete!

    • ✅ A Cart adds, updates, removes and merges lines, keyed by product id
    • ✅ Money lives as integer cents — divide by 100 only to display
    • ✅ Apply the discount first, then tax the discounted amount, rounding once per step
    • Checkout creates the order and adjusts stock inside one transaction — commit or rollBack
    • ✅ An atomic WHERE stock >= ? guard prevents overselling under a race
    • ✅ Orders move through states (pending → paid → shipped → delivered) along legal transitions
    • Next lesson: Template Engines — render those invoices and order pages cleanly with Twig

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service