Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🛠️ PHP + MySQL REST API  ·  Lesson 11

Admin Panel (Products & Orders)

A quick tour

The admin panel is a small website inside backend/admin/. The shop owner opens it in a browser to run the shop. These are real screenshots of the panel you are about to build:

KartEasy admin dashboard with revenue, orders and customers
Dashboard: revenue, new orders, customers, low stock and recent orders
Admin product list with images, price, stock and status
Products: search, filter by category, edit, hide / show
Admin panel on a phone
It also works on a phone: the side menu opens with ☰

Security basics of the panel

Login + sessionsession_regenerate_id(), HttpOnly cookie, 5 tries / 10 minCSRF tokenevery POST form carries a secret the attacker cannot knowh() escapingevery printed value goes through htmlspecialcharsSafe uploadsgetimagesize, 2 MB, random names, no PHP in uploads/Prepared statementssame PDO rules as the API
Five layers protect the admin panel

CSRF (Cross-Site Request Forgery): while the admin is logged in, a bad website could secretly submit a form to /admin/order.php. The hidden csrf field, checked with hash_equals(), makes such forged requests fail. XSS: a customer could type HTML into their name. Printing it with h() shows it as text:

PHP
<?php
$name = '<script>alert("hacked")</script>';   // a customer typed this as their name

echo $name;      // ❌ the script runs in the admin's browser
echo h($name);   // ✅ &lt;script&gt;… shown as plain text

Step 1: inc/bootstrap.php

Every admin page starts with this file. It starts the session, and provides require_admin(), csrf_field() / check_csrf(), h(), flash messages and the safe image upload function.

PHP – backend/admin/inc/bootstrap.php
<?php
// Shared by every admin page: session, login check, CSRF, escaping, uploads.
require_once __DIR__ . '/../../includes/db.php';

session_set_cookie_params(0, '/', '', isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', true);
session_name('KEADMIN');
session_start();

if (DEBUG) {
    ini_set('display_errors', '1');
}

/** Escape text for HTML output. Use it for EVERY value printed in a page. */
function h($s)
{
    return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
}

function admin_url($page, $params = array())
{
    return $page . (count($params) ? '?' . http_build_query($params) : '');
}

function redirect($url)
{
    header('Location: ' . $url);
    exit;
}

function require_admin()
{
    if (empty($_SESSION['admin_id'])) {
        redirect('login.php');
    }
}

function csrf_token()
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(16));
    }
    return $_SESSION['csrf'];
}

function csrf_field()
{
    return '<input type="hidden" name="csrf" value="' . h(csrf_token()) . '">';
}

/** Call at the top of every POST handler. */
function check_csrf()
{
    $sent = isset($_POST['csrf']) ? $_POST['csrf'] : '';
    if (!hash_equals(csrf_token(), $sent)) {
        http_response_code(400);
        exit('Invalid form token. Go back, refresh the page and try again.');
    }
}

function flash($msg = null, $type = 'ok')
{
    if ($msg !== null) {
        $_SESSION['flash'] = array($msg, $type);
        return null;
    }
    if (!empty($_SESSION['flash'])) {
        $f = $_SESSION['flash'];
        unset($_SESSION['flash']);
        return $f;
    }
    return null;
}

function post($key, $default = '')
{
    return isset($_POST[$key]) ? trim((string) $_POST[$key]) : $default;
}

function slugify($text)
{
    $text = strtolower(trim(preg_replace('/[^A-Za-z0-9]+/', '-', $text), '-'));
    return $text === '' ? 'item-' . time() : $text;
}

/**
 * Save an uploaded image into uploads/<folder>/ and return its relative path,
 * or null when no file was chosen. Only real JPG/PNG/WEBP images up to 2 MB.
 */
function save_upload($field, $folder)
{
    if (empty($_FILES[$field]) || $_FILES[$field]['error'] === UPLOAD_ERR_NO_FILE) {
        return null;
    }
    $f = $_FILES[$field];
    if ($f['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException('Upload failed (error ' . $f['error'] . ').');
    }
    if ($f['size'] > 2 * 1024 * 1024) {
        throw new RuntimeException('Image must be smaller than 2 MB.');
    }
    $info = @getimagesize($f['tmp_name']);
    $types = array(IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp');
    if ($info === false || !isset($types[$info[2]])) {
        throw new RuntimeException('Only JPG, PNG or WEBP images are allowed.');
    }
    // Random file name: the user's file name is never used on disk.
    $name = $folder . '-' . bin2hex(random_bytes(6)) . '.' . $types[$info[2]];
    $dir = __DIR__ . '/../../uploads/' . $folder;
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }
    if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $name)) {
        throw new RuntimeException('Could not save the image.');
    }
    return 'uploads/' . $folder . '/' . $name;
}

function img_src($path)
{
    if ($path === null || $path === '') {
        return '';
    }
    return preg_match('#^https?://#', $path) ? $path : '../' . ltrim($path, '/');
}

function rupees($n)
{
    return '₹' . number_format((float) $n, 2);
}

$ORDER_STATUSES = array('placed', 'confirmed', 'shipped', 'delivered', 'cancelled');
File uploaded?UPLOAD_ERR_NO_FILE → keep old imageSize ≤ 2 MB?else errorReal image?getimagesize() → JPG / PNG / WEBP onlyRandom nameproducts-3fa9c1d2e7b0.pngmove_uploaded_fileuploads/products/
save_upload(): never trust the file name or type from the browser

Step 2: layout, menu and CSS

Each page sets $pageTitle and $active, includes the header (top bar + side menu + flash message), prints its content and includes the footer. The CSS is plain, with a mobile breakpoint at 800px.

PHP – backend/admin/inc/header.php
<?php
// $pageTitle and $active are set by the page that includes this file.
$menu = array(
    'index.php' => array('📊', 'Dashboard'),
    'orders.php' => array('📦', 'Orders'),
    'products.php' => array('🛍️', 'Products'),
    'categories.php' => array('🗂️', 'Categories'),
    'banners.php' => array('🖼️', 'Banners'),
    'coupons.php' => array('🏷️', 'Coupons'),
    'users.php' => array('👥', 'Customers'),
);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?= h($pageTitle) ?> · KartEasy Admin</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<header class="top">
  <button class="menu-btn" onclick="document.body.classList.toggle('nav-open')" aria-label="Menu">☰</button>
  <a class="brand" href="index.php">🛒 KartEasy <span>Admin</span></a>
  <div class="who"><?= h(isset($_SESSION['admin_name']) ? $_SESSION['admin_name'] : '') ?> · <a href="logout.php">Logout</a></div>
</header>
<nav class="side">
<?php foreach ($menu as $file => $m): ?>
  <a href="<?= h($file) ?>" class="<?= $active === $file ? 'on' : '' ?>"><span><?= $m[0] ?></span><?= h($m[1]) ?></a>
<?php endforeach; ?>
</nav>
<main class="main">
<h1><?= h($pageTitle) ?></h1>
<?php $f = flash(); if ($f): ?>
<div class="flash <?= h($f[1]) ?>"><?= h($f[0]) ?></div>
<?php endif; ?>
PHP – backend/admin/inc/footer.php
</main>
</body>
</html>
CSS – backend/admin/assets/admin.css
*{box-sizing:border-box}
:root{--brand:#4f46e5;--brand-d:#3730a3;--bg:#f4f5fb;--card:#fff;--text:#1f2937;--muted:#6b7280;--line:#e5e7eb;--ok:#059669;--err:#dc2626}
body{margin:0;font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text)}
a{color:var(--brand)}
.top{position:fixed;inset:0 0 auto 0;height:56px;background:var(--brand);color:#fff;display:flex;align-items:center;gap:12px;padding:0 16px;z-index:10}
.top .brand{color:#fff;text-decoration:none;font-weight:700;font-size:18px}
.top .brand span{font-weight:400;opacity:.8}
.top .who{margin-left:auto;font-size:14px}
.top .who a{color:#fff}
.menu-btn{display:none;background:none;border:0;color:#fff;font-size:22px;cursor:pointer}
.side{position:fixed;top:56px;bottom:0;left:0;width:220px;background:#fff;border-right:1px solid var(--line);padding:12px 8px}
.side a{display:flex;gap:10px;align-items:center;padding:10px 12px;border-radius:8px;color:var(--text);text-decoration:none;margin-bottom:2px}
.side a:hover{background:#eef2ff}
.side a.on{background:var(--brand);color:#fff}
.main{margin:56px 0 0 220px;padding:24px;max-width:1200px}
h1{margin:0 0 16px;font-size:24px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}
.card .n{font-size:28px;font-weight:700}
.card .l{color:var(--muted);font-size:13px}
.box{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:20px;overflow-x:auto}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
th{font-size:12px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}
td img{width:44px;height:44px;object-fit:cover;border-radius:8px;background:#eee}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;text-decoration:none}
.btn:hover{background:var(--brand-d)}
.btn.sm{padding:5px 10px;font-size:13px}
.btn.gray{background:#e5e7eb;color:var(--text)}
.btn.red{background:var(--err)}
form.inline{display:inline}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
.toolbar input,.toolbar select{padding:8px;border:1px solid var(--line);border-radius:8px}
.form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.form .full{grid-column:1/-1}
.form label{display:block;font-size:13px;font-weight:600;margin-bottom:4px}
.form input,.form select,.form textarea{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;font:inherit}
.form textarea{min-height:110px}
.check{display:flex;gap:8px;align-items:center}
.check input{width:auto}
.flash{padding:12px 14px;border-radius:8px;margin-bottom:16px;background:#ecfdf5;color:var(--ok);border:1px solid #a7f3d0}
.flash.err{background:#fef2f2;color:var(--err);border-color:#fecaca}
.badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:600;background:#eef2ff;color:var(--brand)}
.badge.placed{background:#fef3c7;color:#92400e}
.badge.confirmed{background:#dbeafe;color:#1e40af}
.badge.shipped{background:#e0e7ff;color:#3730a3}
.badge.delivered{background:#d1fae5;color:#065f46}
.badge.cancelled{background:#fee2e2;color:#991b1b}
.badge.off{background:#f3f4f6;color:var(--muted)}
.muted{color:var(--muted)}
.login{max-width:380px;margin:12vh auto;background:#fff;border:1px solid var(--line);border-radius:16px;padding:28px}
.login h1{text-align:center}
.login input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;margin:6px 0 14px;font:inherit}
.login .btn{width:100%;padding:11px}
@media (max-width:800px){
  .menu-btn{display:block}
  .side{transform:translateX(-100%);transition:transform .2s;z-index:9}
  body.nav-open .side{transform:none;box-shadow:0 0 40px rgba(0,0,0,.2)}
  .main{margin-left:0;padding:16px}
  .form{grid-template-columns:1fr}
  .cards{grid-template-columns:1fr 1fr;gap:10px}
  .card .n{font-size:20px}
  .top .who{font-size:12px}
}

Step 3: login and logout

KartEasy admin login page
admin/login.php

After a correct password we call session_regenerate_id(true) — a new session id after login stops "session fixation" attacks. Failed attempts are counted in the session; after 5 in 10 minutes the form refuses.

PHP – backend/admin/login.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';

if (!empty($_SESSION['admin_id'])) {
    redirect('index.php');
}

$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    // Slow down password guessing: max 5 failed tries per 10 minutes per session.
    $tries = isset($_SESSION['login_tries']) ? $_SESSION['login_tries'] : array();
    $tries = array_filter($tries, function ($t) {
        return $t > time() - 600;
    });
    if (count($tries) >= 5) {
        $error = 'Too many attempts. Please wait 10 minutes.';
    } else {
        $stmt = db()->prepare('SELECT id, name, password_hash FROM admins WHERE email = ?');
        $stmt->execute(array(strtolower(post('email'))));
        $admin = $stmt->fetch();
        if ($admin && password_verify(post('password'), $admin['password_hash'])) {
            session_regenerate_id(true); // new session id after login (stops session fixation)
            $_SESSION['admin_id'] = (int) $admin['id'];
            $_SESSION['admin_name'] = $admin['name'];
            unset($_SESSION['login_tries']);
            redirect('index.php');
        }
        $tries[] = time();
        $error = 'Wrong email or password.';
    }
    $_SESSION['login_tries'] = $tries;
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Login · KartEasy Admin</title>
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<form class="login" method="post">
  <h1>🛒 KartEasy Admin</h1>
  <?php if ($error): ?><div class="flash err"><?= h($error) ?></div><?php endif; ?>
  <?= csrf_field() ?>
  <label>Email</label>
  <input type="email" name="email" required value="<?= h(post('email')) ?>" autocomplete="username">
  <label>Password</label>
  <input type="password" name="password" required autocomplete="current-password">
  <button class="btn">Login</button>
</form>
</body>
</html>
PHP – backend/admin/logout.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
$_SESSION = array();
session_destroy();
redirect('login.php');

Step 4: dashboard

One query with six sub-selects fills all the number cards. Revenue excludes cancelled orders. Products with stock ≤ 5 are listed so the owner can restock.

PHP – backend/admin/index.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();

$pdo = db();
$stats = $pdo->query(
    "SELECT
      (SELECT COUNT(*) FROM orders) AS orders,
      (SELECT COUNT(*) FROM orders WHERE status = 'placed') AS pending,
      (SELECT COALESCE(SUM(total), 0) FROM orders WHERE status <> 'cancelled') AS revenue,
      (SELECT COUNT(*) FROM users) AS users,
      (SELECT COUNT(*) FROM products WHERE is_active = 1) AS products,
      (SELECT COUNT(*) FROM products WHERE is_active = 1 AND stock <= 5) AS low_stock"
)->fetch();

$recent = $pdo->query(
    'SELECT o.id, o.order_no, o.total, o.status, o.created_at, u.name
     FROM orders o JOIN users u ON u.id = o.user_id ORDER BY o.id DESC LIMIT 8'
)->fetchAll();

$low = $pdo->query('SELECT id, name, stock, image FROM products WHERE is_active = 1 AND stock <= 5 ORDER BY stock LIMIT 8')->fetchAll();

$pageTitle = 'Dashboard';
$active = 'index.php';
require __DIR__ . '/inc/header.php';
?>
<div class="cards">
  <div class="card"><div class="n"><?= rupees($stats['revenue']) ?></div><div class="l">Revenue (excluding cancelled)</div></div>
  <div class="card"><div class="n"><?= (int) $stats['orders'] ?></div><div class="l">Total orders</div></div>
  <div class="card"><div class="n"><?= (int) $stats['pending'] ?></div><div class="l">New orders to confirm</div></div>
  <div class="card"><div class="n"><?= (int) $stats['users'] ?></div><div class="l">Customers</div></div>
  <div class="card"><div class="n"><?= (int) $stats['products'] ?></div><div class="l">Active products</div></div>
  <div class="card"><div class="n"><?= (int) $stats['low_stock'] ?></div><div class="l">Low stock (≤ 5)</div></div>
</div>

<div class="box">
  <h3>Recent orders</h3>
  <table>
    <tr><th>Order</th><th>Customer</th><th>Total</th><th>Status</th><th>Date</th></tr>
    <?php foreach ($recent as $o): ?>
    <tr>
      <td><a href="order.php?id=<?= (int) $o['id'] ?>"><?= h($o['order_no']) ?></a></td>
      <td><?= h($o['name']) ?></td>
      <td><?= rupees($o['total']) ?></td>
      <td><span class="badge <?= h($o['status']) ?>"><?= h($o['status']) ?></span></td>
      <td class="muted"><?= h(date('d M Y, h:i A', strtotime($o['created_at']))) ?></td>
    </tr>
    <?php endforeach; ?>
    <?php if (!$recent): ?><tr><td colspan="5" class="muted">No orders yet.</td></tr><?php endif; ?>
  </table>
</div>

<?php if ($low): ?>
<div class="box">
  <h3>Low stock</h3>
  <table>
    <tr><th></th><th>Product</th><th>Stock</th><th></th></tr>
    <?php foreach ($low as $p): ?>
    <tr>
      <td><img src="<?= h(img_src($p['image'])) ?>" alt=""></td>
      <td><?= h($p['name']) ?></td>
      <td><strong><?= (int) $p['stock'] ?></strong></td>
      <td><a class="btn sm" href="product-edit.php?id=<?= (int) $p['id'] ?>">Update</a></td>
    </tr>
    <?php endforeach; ?>
  </table>
</div>
<?php endif; ?>
<?php require __DIR__ . '/inc/footer.php';

Step 5: orders and status

Admin orders list with status badges
Orders: search by order number, name or phone; filter by status
PHP – backend/admin/orders.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();

$status = isset($_GET['status']) ? $_GET['status'] : '';
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
$where = array('1 = 1');
$params = array();
if (in_array($status, $ORDER_STATUSES, true)) {
    $where[] = 'o.status = ?';
    $params[] = $status;
}
if ($q !== '') {
    $where[] = '(o.order_no LIKE ? OR u.name LIKE ? OR o.ship_phone LIKE ?)';
    $params[] = '%' . $q . '%';
    $params[] = '%' . $q . '%';
    $params[] = '%' . $q . '%';
}
$stmt = db()->prepare(
    'SELECT o.*, u.name AS customer,
       (SELECT SUM(qty) FROM order_items i WHERE i.order_id = o.id) AS items
     FROM orders o JOIN users u ON u.id = o.user_id
     WHERE ' . implode(' AND ', $where) . ' ORDER BY o.id DESC LIMIT 200'
);
$stmt->execute($params);
$orders = $stmt->fetchAll();

$pageTitle = 'Orders';
$active = 'orders.php';
require __DIR__ . '/inc/header.php';
?>
<form class="toolbar" method="get">
  <input type="search" name="q" value="<?= h($q) ?>" placeholder="Order no, name or phone">
  <select name="status">
    <option value="">All statuses</option>
    <?php foreach ($ORDER_STATUSES as $s): ?>
    <option value="<?= h($s) ?>" <?= $status === $s ? 'selected' : '' ?>><?= h(ucfirst($s)) ?></option>
    <?php endforeach; ?>
  </select>
  <button class="btn gray">Filter</button>
</form>
<div class="box">
<table>
  <tr><th>Order</th><th>Customer</th><th>Items</th><th>Total</th><th>Payment</th><th>Status</th><th>Date</th></tr>
  <?php foreach ($orders as $o): ?>
  <tr>
    <td><a href="order.php?id=<?= (int) $o['id'] ?>"><?= h($o['order_no']) ?></a></td>
    <td><?= h($o['customer']) ?><br><span class="muted"><?= h($o['ship_phone']) ?></span></td>
    <td><?= (int) $o['items'] ?></td>
    <td><?= rupees($o['total']) ?></td>
    <td><?= h($o['payment_method']) ?></td>
    <td><span class="badge <?= h($o['status']) ?>"><?= h($o['status']) ?></span></td>
    <td class="muted"><?= h(date('d M, h:i A', strtotime($o['created_at']))) ?></td>
  </tr>
  <?php endforeach; ?>
  <?php if (!$orders): ?><tr><td colspan="7" class="muted">No orders found.</td></tr><?php endif; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';
Admin order detail with items and price breakdown
Order page: ship-to address, items, discount, total, and the status selector

Changing the status runs in a transaction. Moving to cancelled returns the stock. A delivered or cancelled order is locked. The customer sees the new status the next time the app opens the order.

PHP – backend/admin/order.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    $new = post('status');
    $pdo->beginTransaction();
    $cur = $pdo->prepare('SELECT status FROM orders WHERE id = ? FOR UPDATE');
    $cur->execute(array($id));
    $old = $cur->fetchColumn();
    if ($old === false || !in_array($new, $ORDER_STATUSES, true)) {
        $pdo->rollBack();
        flash('Invalid order or status.', 'err');
    } elseif ($old === 'cancelled' || $old === 'delivered') {
        $pdo->rollBack();
        flash('A ' . $old . ' order cannot be changed.', 'err');
    } else {
        $pdo->prepare('UPDATE orders SET status = ? WHERE id = ?')->execute(array($new, $id));
        if ($new === 'cancelled') {
            // Put the stock back
            $pdo->prepare(
                'UPDATE products p JOIN order_items i ON i.product_id = p.id
                 SET p.stock = p.stock + i.qty WHERE i.order_id = ?'
            )->execute(array($id));
        }
        $pdo->commit();
        flash('Order status changed to ' . $new . '.');
    }
    redirect('order.php?id=' . $id);
}

$stmt = $pdo->prepare('SELECT o.*, u.name AS customer, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.id = ?');
$stmt->execute(array($id));
$o = $stmt->fetch();
if (!$o) {
    flash('Order not found.', 'err');
    redirect('orders.php');
}
$it = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ?');
$it->execute(array($id));
$items = $it->fetchAll();

$pageTitle = 'Order ' . $o['order_no'];
$active = 'orders.php';
require __DIR__ . '/inc/header.php';
?>
<div class="cards">
  <div class="card"><div class="l">Status</div><div class="n" style="font-size:20px"><span class="badge <?= h($o['status']) ?>"><?= h($o['status']) ?></span></div></div>
  <div class="card"><div class="l">Total</div><div class="n" style="font-size:20px"><?= rupees($o['total']) ?></div></div>
  <div class="card"><div class="l">Payment</div><div class="n" style="font-size:20px"><?= h($o['payment_method']) ?></div></div>
  <div class="card"><div class="l">Placed on</div><div class="n" style="font-size:16px"><?= h(date('d M Y, h:i A', strtotime($o['created_at']))) ?></div></div>
</div>

<?php if (!in_array($o['status'], array('cancelled', 'delivered'), true)): ?>
<form class="box toolbar" method="post">
  <?= csrf_field() ?>
  <strong>Change status:</strong>
  <select name="status">
    <?php foreach ($ORDER_STATUSES as $s): ?>
    <option value="<?= h($s) ?>" <?= $o['status'] === $s ? 'selected' : '' ?>><?= h(ucfirst($s)) ?></option>
    <?php endforeach; ?>
  </select>
  <button class="btn">Update</button>
</form>
<?php endif; ?>

<div class="box">
  <h3>Ship to</h3>
  <p><strong><?= h($o['ship_name']) ?></strong> · <?= h($o['ship_phone']) ?><br><?= h($o['ship_address']) ?></p>
  <p class="muted">Customer account: <?= h($o['customer']) ?> (<?= h($o['email']) ?>)</p>
</div>

<div class="box">
<table>
  <tr><th></th><th>Product</th><th>Price</th><th>Qty</th><th>Amount</th></tr>
  <?php foreach ($items as $i): ?>
  <tr>
    <td><img src="<?= h(img_src($i['image'])) ?>" alt=""></td>
    <td><?= h($i['name']) ?></td>
    <td><?= rupees($i['price']) ?></td>
    <td><?= (int) $i['qty'] ?></td>
    <td><?= rupees($i['price'] * $i['qty']) ?></td>
  </tr>
  <?php endforeach; ?>
  <tr><td colspan="4" style="text-align:right">Subtotal</td><td><?= rupees($o['subtotal']) ?></td></tr>
  <?php if ((float) $o['discount'] > 0): ?>
  <tr><td colspan="4" style="text-align:right">Discount (<?= h($o['coupon_code']) ?>)</td><td>− <?= rupees($o['discount']) ?></td></tr>
  <?php endif; ?>
  <tr><td colspan="4" style="text-align:right">Delivery</td><td><?= (float) $o['delivery_fee'] > 0 ? rupees($o['delivery_fee']) : 'Free' ?></td></tr>
  <tr><td colspan="4" style="text-align:right"><strong>Total</strong></td><td><strong><?= rupees($o['total']) ?></strong></td></tr>
</table>
</div>
<a class="btn gray" href="orders.php">← All orders</a>
<?php require __DIR__ . '/inc/footer.php';

Step 6: products with image upload

Products are never deleted — the Hide button flips is_active, so old orders stay correct. The edit form validates price ≤ MRP and stock ≥ 0, uploads a main image and optional gallery images.

PHP – backend/admin/products.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();

// Show / hide a product. We never DELETE products because old orders point to them.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    $pdo->prepare('UPDATE products SET is_active = 1 - is_active WHERE id = ?')->execute(array((int) post('id')));
    flash('Product visibility changed.');
    redirect('products.php?' . http_build_query($_GET));
}

$q = isset($_GET['q']) ? trim($_GET['q']) : '';
$cat = isset($_GET['category']) ? (int) $_GET['category'] : 0;
$where = array('1 = 1');
$params = array();
if ($q !== '') {
    $where[] = 'p.name LIKE ?';
    $params[] = '%' . $q . '%';
}
if ($cat > 0) {
    $where[] = 'p.category_id = ?';
    $params[] = $cat;
}
$stmt = $pdo->prepare(
    'SELECT p.*, c.name AS category FROM products p JOIN categories c ON c.id = p.category_id
     WHERE ' . implode(' AND ', $where) . ' ORDER BY p.id DESC'
);
$stmt->execute($params);
$products = $stmt->fetchAll();
$categories = $pdo->query('SELECT id, name FROM categories ORDER BY sort_order, name')->fetchAll();

$pageTitle = 'Products';
$active = 'products.php';
require __DIR__ . '/inc/header.php';
?>
<form class="toolbar" method="get">
  <input type="search" name="q" value="<?= h($q) ?>" placeholder="Search products">
  <select name="category">
    <option value="0">All categories</option>
    <?php foreach ($categories as $c): ?>
    <option value="<?= (int) $c['id'] ?>" <?= $cat === (int) $c['id'] ? 'selected' : '' ?>><?= h($c['name']) ?></option>
    <?php endforeach; ?>
  </select>
  <button class="btn gray">Filter</button>
  <a class="btn" href="product-edit.php" style="margin-left:auto">+ Add product</a>
</form>
<div class="box">
<table>
  <tr><th></th><th>Name</th><th>Category</th><th>Price</th><th>MRP</th><th>Stock</th><th>Status</th><th></th></tr>
  <?php foreach ($products as $p): ?>
  <tr>
    <td><img src="<?= h(img_src($p['image'])) ?>" alt=""></td>
    <td><?= h($p['name']) ?><?= $p['is_featured'] ? ' <span class="badge">Featured</span>' : '' ?></td>
    <td><?= h($p['category']) ?></td>
    <td><?= rupees($p['price']) ?></td>
    <td class="muted"><?= rupees($p['mrp']) ?></td>
    <td><?= (int) $p['stock'] ?></td>
    <td><?= $p['is_active'] ? '<span class="badge delivered">Live</span>' : '<span class="badge off">Hidden</span>' ?></td>
    <td style="white-space:nowrap">
      <a class="btn sm" href="product-edit.php?id=<?= (int) $p['id'] ?>">Edit</a>
      <form class="inline" method="post">
        <?= csrf_field() ?><input type="hidden" name="id" value="<?= (int) $p['id'] ?>">
        <button class="btn sm gray"><?= $p['is_active'] ? 'Hide' : 'Show' ?></button>
      </form>
    </td>
  </tr>
  <?php endforeach; ?>
  <?php if (!$products): ?><tr><td colspan="8" class="muted">No products found.</td></tr><?php endif; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';
Admin edit product form
product-edit.php: the same form adds and edits
PHP – backend/admin/product-edit.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();

$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$p = array('category_id' => 0, 'name' => '', 'short_desc' => '', 'description' => '', 'price' => '', 'mrp' => '',
    'stock' => 0, 'image' => null, 'is_featured' => 0, 'is_active' => 1);
if ($id > 0) {
    $stmt = $pdo->prepare('SELECT * FROM products WHERE id = ?');
    $stmt->execute(array($id));
    $p = $stmt->fetch();
    if (!$p) {
        flash('Product not found.', 'err');
        redirect('products.php');
    }
}

$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();

    // Delete one gallery image
    if (post('delete_image') !== '') {
        $pdo->prepare('DELETE FROM product_images WHERE id = ? AND product_id = ?')->execute(array((int) post('delete_image'), $id));
        flash('Image removed.');
        redirect('product-edit.php?id=' . $id);
    }

    $data = array(
        'category_id' => (int) post('category_id'),
        'name' => post('name'),
        'short_desc' => post('short_desc'),
        'description' => post('description'),
        'price' => (float) post('price'),
        'mrp' => (float) post('mrp'),
        'stock' => (int) post('stock'),
        'is_featured' => isset($_POST['is_featured']) ? 1 : 0,
        'is_active' => isset($_POST['is_active']) ? 1 : 0,
    );
    if ($data['name'] === '' || $data['category_id'] <= 0) {
        $error = 'Name and category are required.';
    } elseif ($data['price'] <= 0 || $data['mrp'] < $data['price']) {
        $error = 'Price must be more than 0 and MRP must be at least the price.';
    } elseif ($data['stock'] < 0) {
        $error = 'Stock cannot be negative.';
    }

    if ($error === '') {
        try {
            $image = save_upload('image', 'products');
            if ($image !== null) {
                $data['image'] = $image;
            } elseif ($id === 0) {
                throw new RuntimeException('Please choose a product image.');
            }
            $cols = array_keys($data);
            if ($id > 0) {
                $set = implode(' = ?, ', $cols) . ' = ?';
                $vals = array_values($data);
                $vals[] = $id;
                $pdo->prepare("UPDATE products SET $set WHERE id = ?")->execute($vals);
            } else {
                $cols[] = 'slug';
                $vals = array_values($data);
                $vals[] = slugify($data['name']) . '-' . substr(bin2hex(random_bytes(3)), 0, 4);
                $marks = implode(', ', array_fill(0, count($cols), '?'));
                $pdo->prepare('INSERT INTO products (' . implode(', ', $cols) . ") VALUES ($marks)")->execute($vals);
                $id = (int) $pdo->lastInsertId();
            }
            // Extra gallery image (optional)
            $extra = save_upload('gallery', 'products');
            if ($extra !== null) {
                $pdo->prepare('INSERT INTO product_images (product_id, image, sort_order) VALUES (?, ?, 99)')->execute(array($id, $extra));
            }
            flash('Product saved.');
            redirect('product-edit.php?id=' . $id);
        } catch (RuntimeException $e) {
            $error = $e->getMessage();
        }
    }
    $p = array_merge($p, $data);
}

$categories = $pdo->query('SELECT id, name FROM categories ORDER BY sort_order, name')->fetchAll();
$gallery = array();
if ($id > 0) {
    $g = $pdo->prepare('SELECT id, image FROM product_images WHERE product_id = ? ORDER BY sort_order, id');
    $g->execute(array($id));
    $gallery = $g->fetchAll();
}

$pageTitle = $id > 0 ? 'Edit product' : 'Add product';
$active = 'products.php';
require __DIR__ . '/inc/header.php';
?>
<?php if ($error): ?><div class="flash err"><?= h($error) ?></div><?php endif; ?>
<form class="box form" method="post" enctype="multipart/form-data">
  <?= csrf_field() ?>
  <div class="full"><label>Product name</label><input name="name" required maxlength="150" value="<?= h($p['name']) ?>"></div>
  <div>
    <label>Category</label>
    <select name="category_id" required>
      <option value="">Choose…</option>
      <?php foreach ($categories as $c): ?>
      <option value="<?= (int) $c['id'] ?>" <?= (int) $p['category_id'] === (int) $c['id'] ? 'selected' : '' ?>><?= h($c['name']) ?></option>
      <?php endforeach; ?>
    </select>
  </div>
  <div><label>Stock</label><input type="number" name="stock" min="0" value="<?= (int) $p['stock'] ?>"></div>
  <div><label>Selling price (₹)</label><input type="number" step="0.01" name="price" required value="<?= h($p['price']) ?>"></div>
  <div><label>MRP (₹)</label><input type="number" step="0.01" name="mrp" required value="<?= h($p['mrp']) ?>"></div>
  <div class="full"><label>Short description (shown on cards)</label><input name="short_desc" maxlength="255" value="<?= h($p['short_desc']) ?>"></div>
  <div class="full"><label>Full description</label><textarea name="description"><?= h($p['description']) ?></textarea></div>
  <div>
    <label>Main image <?= $id ? '(leave empty to keep)' : '' ?></label>
    <?php if ($p['image']): ?><img src="<?= h(img_src($p['image'])) ?>" alt="" style="width:90px;border-radius:8px;display:block;margin-bottom:6px"><?php endif; ?>
    <input type="file" name="image" accept="image/png,image/jpeg,image/webp">
  </div>
  <div><label>Add a gallery image (optional)</label><input type="file" name="gallery" accept="image/png,image/jpeg,image/webp"></div>
  <div class="check"><input type="checkbox" id="f" name="is_featured" <?= $p['is_featured'] ? 'checked' : '' ?>><label for="f">Featured on home</label></div>
  <div class="check"><input type="checkbox" id="a" name="is_active" <?= $p['is_active'] ? 'checked' : '' ?>><label for="a">Visible in app</label></div>
  <div class="full"><button class="btn">Save product</button> <a class="btn gray" href="products.php">Back</a></div>
</form>

<?php if ($gallery): ?>
<div class="box">
  <h3>Gallery</h3>
  <?php foreach ($gallery as $g): ?>
  <form class="inline" method="post" style="display:inline-block;margin:0 10px 10px 0;text-align:center">
    <?= csrf_field() ?><input type="hidden" name="delete_image" value="<?= (int) $g['id'] ?>">
    <img src="<?= h(img_src($g['image'])) ?>" alt="" style="width:90px;height:90px;object-fit:cover;border-radius:8px;display:block">
    <button class="btn sm red" onclick="return confirm('Remove this image?')">Remove</button>
  </form>
  <?php endforeach; ?>
</div>
<?php endif; ?>
<?php require __DIR__ . '/inc/footer.php';

Step 7: categories, banners, coupons, customers

Admin banners page
Banners: upload a 1200 × 500 image and choose what it opens
PHP – backend/admin/banners.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    if (post('toggle') !== '') {
        $pdo->prepare('UPDATE banners SET is_active = 1 - is_active WHERE id = ?')->execute(array((int) post('toggle')));
        flash('Banner visibility changed.');
        redirect('banners.php');
    }
    if (post('delete') !== '') {
        $pdo->prepare('DELETE FROM banners WHERE id = ?')->execute(array((int) post('delete')));
        flash('Banner deleted.');
        redirect('banners.php');
    }
    try {
        $title = post('title');
        $type = post('link_type', 'none');
        if (!in_array($type, array('none', 'category', 'product'), true)) {
            $type = 'none';
        }
        $linkId = $type === 'none' ? null : (int) post('link_id');
        if ($title === '') {
            throw new RuntimeException('Title is required.');
        }
        $image = save_upload('image', 'banners');
        if ($image === null) {
            throw new RuntimeException('Please choose a banner image (recommended size 1200 × 500).');
        }
        $pdo->prepare('INSERT INTO banners (title, subtitle, image, link_type, link_id, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
            ->execute(array($title, post('subtitle'), $image, $type, $linkId, (int) post('sort_order')));
        flash('Banner added.');
    } catch (RuntimeException $e) {
        flash($e->getMessage(), 'err');
    }
    redirect('banners.php');
}

$rows = $pdo->query('SELECT * FROM banners ORDER BY sort_order, id')->fetchAll();
$pageTitle = 'Home banners';
$active = 'banners.php';
require __DIR__ . '/inc/header.php';
?>
<form class="box form" method="post" enctype="multipart/form-data">
  <?= csrf_field() ?>
  <h3 class="full" style="margin:0">Add banner</h3>
  <div><label>Title</label><input name="title" required maxlength="100"></div>
  <div><label>Subtitle</label><input name="subtitle" maxlength="150"></div>
  <div>
    <label>On tap open</label>
    <select name="link_type"><option value="none">Nothing</option><option value="category">Category</option><option value="product">Product</option></select>
  </div>
  <div><label>Category / product ID</label><input type="number" name="link_id" min="0"></div>
  <div><label>Sort order</label><input type="number" name="sort_order" value="<?= count($rows) + 1 ?>"></div>
  <div><label>Image (1200 × 500)</label><input type="file" name="image" required accept="image/png,image/jpeg,image/webp"></div>
  <div class="full"><button class="btn">Add banner</button></div>
</form>
<div class="box">
<table>
  <tr><th></th><th>Title</th><th>Opens</th><th>Order</th><th>Status</th><th></th></tr>
  <?php foreach ($rows as $b): ?>
  <tr>
    <td><img src="<?= h(img_src($b['image'])) ?>" alt="" style="width:110px"></td>
    <td><?= h($b['title']) ?><br><span class="muted"><?= h($b['subtitle']) ?></span></td>
    <td><?= h($b['link_type']) ?><?= $b['link_id'] ? ' #' . (int) $b['link_id'] : '' ?></td>
    <td><?= (int) $b['sort_order'] ?></td>
    <td><?= $b['is_active'] ? '<span class="badge delivered">Live</span>' : '<span class="badge off">Hidden</span>' ?></td>
    <td style="white-space:nowrap">
      <form class="inline" method="post"><?= csrf_field() ?><input type="hidden" name="toggle" value="<?= (int) $b['id'] ?>"><button class="btn sm gray"><?= $b['is_active'] ? 'Hide' : 'Show' ?></button></form>
      <form class="inline" method="post"><?= csrf_field() ?><input type="hidden" name="delete" value="<?= (int) $b['id'] ?>"><button class="btn sm red" onclick="return confirm('Delete this banner?')">Delete</button></form>
    </td>
  </tr>
  <?php endforeach; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';
Admin coupons page
Coupons: create flat or percent codes with minimum order, cap and expiry
PHP – backend/admin/coupons.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    if (post('toggle') !== '') {
        $pdo->prepare('UPDATE coupons SET is_active = 1 - is_active WHERE id = ?')->execute(array((int) post('toggle')));
        flash('Coupon status changed.');
        redirect('coupons.php');
    }
    $code = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', post('code')));
    $type = post('type') === 'percent' ? 'percent' : 'flat';
    $value = (float) post('value');
    $max = post('max_discount') === '' ? null : (float) post('max_discount');
    $expires = post('expires_at') === '' ? null : date('Y-m-d 23:59:59', strtotime(post('expires_at')));
    if ($code === '' || $value <= 0 || ($type === 'percent' && $value > 90)) {
        flash('Enter a code and a value (percent coupons: 1–90).', 'err');
        redirect('coupons.php');
    }
    $exists = $pdo->prepare('SELECT id FROM coupons WHERE code = ?');
    $exists->execute(array($code));
    if ($exists->fetch()) {
        flash('Coupon ' . $code . ' already exists.', 'err');
        redirect('coupons.php');
    }
    $pdo->prepare('INSERT INTO coupons (code, type, value, min_order, max_discount, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
        ->execute(array($code, $type, $value, (float) post('min_order'), $max, $expires));
    flash('Coupon ' . $code . ' created.');
    redirect('coupons.php');
}

$rows = $pdo->query(
    'SELECT c.*, (SELECT COUNT(*) FROM orders o WHERE o.coupon_code = c.code) AS used
     FROM coupons c ORDER BY c.id DESC'
)->fetchAll();
$pageTitle = 'Coupons';
$active = 'coupons.php';
require __DIR__ . '/inc/header.php';
?>
<form class="box form" method="post">
  <?= csrf_field() ?>
  <h3 class="full" style="margin:0">New coupon</h3>
  <div><label>Code</label><input name="code" required maxlength="30" placeholder="DIWALI20" style="text-transform:uppercase"></div>
  <div><label>Type</label><select name="type"><option value="flat">Flat ₹ off</option><option value="percent">Percent % off</option></select></div>
  <div><label>Value</label><input type="number" step="0.01" name="value" required></div>
  <div><label>Minimum order (₹)</label><input type="number" step="0.01" name="min_order" value="0"></div>
  <div><label>Max discount (₹, for percent)</label><input type="number" step="0.01" name="max_discount"></div>
  <div><label>Expires on</label><input type="date" name="expires_at"></div>
  <div class="full"><button class="btn">Create coupon</button></div>
</form>
<div class="box">
<table>
  <tr><th>Code</th><th>Offer</th><th>Min order</th><th>Expires</th><th>Used</th><th>Status</th><th></th></tr>
  <?php foreach ($rows as $c): ?>
  <tr>
    <td><strong><?= h($c['code']) ?></strong></td>
    <td><?= $c['type'] === 'percent' ? (float) $c['value'] . '%' . ($c['max_discount'] !== null ? ' (max ' . rupees($c['max_discount']) . ')' : '') : rupees($c['value']) ?></td>
    <td><?= rupees($c['min_order']) ?></td>
    <td class="muted"><?= $c['expires_at'] ? h(date('d M Y', strtotime($c['expires_at']))) : 'Never' ?></td>
    <td><?= (int) $c['used'] ?></td>
    <td><?= $c['is_active'] ? '<span class="badge delivered">Active</span>' : '<span class="badge off">Off</span>' ?></td>
    <td><form class="inline" method="post"><?= csrf_field() ?><input type="hidden" name="toggle" value="<?= (int) $c['id'] ?>"><button class="btn sm gray"><?= $c['is_active'] ? 'Disable' : 'Enable' ?></button></form></td>
  </tr>
  <?php endforeach; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';
Admin categories page
Categories: add, rename, reorder, change icon, hide
PHP – backend/admin/categories.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();
$pdo = db();

$editId = isset($_GET['edit']) ? (int) $_GET['edit'] : 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    check_csrf();
    if (post('toggle') !== '') {
        $pdo->prepare('UPDATE categories SET is_active = 1 - is_active WHERE id = ?')->execute(array((int) post('toggle')));
        flash('Category visibility changed.');
        redirect('categories.php');
    }
    $id = (int) post('id');
    $name = post('name');
    $sort = (int) post('sort_order');
    try {
        if ($name === '') {
            throw new RuntimeException('Category name is required.');
        }
        $image = save_upload('image', 'categories');
        if ($id > 0) {
            $pdo->prepare('UPDATE categories SET name = ?, sort_order = ? WHERE id = ?')->execute(array($name, $sort, $id));
            if ($image !== null) {
                $pdo->prepare('UPDATE categories SET image = ? WHERE id = ?')->execute(array($image, $id));
            }
        } else {
            $pdo->prepare('INSERT INTO categories (name, slug, image, sort_order) VALUES (?, ?, ?, ?)')
                ->execute(array($name, slugify($name) . '-' . substr(bin2hex(random_bytes(2)), 0, 3), $image, $sort));
        }
        flash('Category saved.');
        redirect('categories.php');
    } catch (RuntimeException $e) {
        flash($e->getMessage(), 'err');
        redirect('categories.php' . ($id ? '?edit=' . $id : ''));
    }
}

$rows = $pdo->query(
    'SELECT c.*, (SELECT COUNT(*) FROM products p WHERE p.category_id = c.id) AS products
     FROM categories c ORDER BY c.sort_order, c.name'
)->fetchAll();
$edit = array('id' => 0, 'name' => '', 'sort_order' => count($rows) + 1, 'image' => null);
foreach ($rows as $r) {
    if ((int) $r['id'] === $editId) {
        $edit = $r;
    }
}

$pageTitle = 'Categories';
$active = 'categories.php';
require __DIR__ . '/inc/header.php';
?>
<form class="box form" method="post" enctype="multipart/form-data">
  <?= csrf_field() ?><input type="hidden" name="id" value="<?= (int) $edit['id'] ?>">
  <h3 class="full" style="margin:0"><?= $edit['id'] ? 'Edit category' : 'Add category' ?></h3>
  <div><label>Name</label><input name="name" required maxlength="60" value="<?= h($edit['name']) ?>"></div>
  <div><label>Sort order</label><input type="number" name="sort_order" value="<?= (int) $edit['sort_order'] ?>"></div>
  <div class="full"><label>Icon image (square PNG works best)</label><input type="file" name="image" accept="image/png,image/jpeg,image/webp"></div>
  <div class="full"><button class="btn">Save</button><?php if ($edit['id']): ?> <a class="btn gray" href="categories.php">Cancel</a><?php endif; ?></div>
</form>
<div class="box">
<table>
  <tr><th></th><th>Name</th><th>Products</th><th>Order</th><th>Status</th><th></th></tr>
  <?php foreach ($rows as $c): ?>
  <tr>
    <td><img src="<?= h(img_src($c['image'])) ?>" alt=""></td>
    <td><?= h($c['name']) ?></td>
    <td><?= (int) $c['products'] ?></td>
    <td><?= (int) $c['sort_order'] ?></td>
    <td><?= $c['is_active'] ? '<span class="badge delivered">Live</span>' : '<span class="badge off">Hidden</span>' ?></td>
    <td style="white-space:nowrap">
      <a class="btn sm" href="categories.php?edit=<?= (int) $c['id'] ?>">Edit</a>
      <form class="inline" method="post"><?= csrf_field() ?><input type="hidden" name="toggle" value="<?= (int) $c['id'] ?>"><button class="btn sm gray"><?= $c['is_active'] ? 'Hide' : 'Show' ?></button></form>
    </td>
  </tr>
  <?php endforeach; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';
PHP – backend/admin/users.php
<?php
require_once __DIR__ . '/inc/bootstrap.php';
require_admin();

$q = isset($_GET['q']) ? trim($_GET['q']) : '';
$sql = 'SELECT u.id, u.name, u.email, u.phone, u.created_at,
          (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS orders,
          (SELECT COALESCE(SUM(total), 0) FROM orders o WHERE o.user_id = u.id AND o.status <> \'cancelled\') AS spent
        FROM users u';
$params = array();
if ($q !== '') {
    $sql .= ' WHERE u.name LIKE ? OR u.email LIKE ? OR u.phone LIKE ?';
    $params = array('%' . $q . '%', '%' . $q . '%', '%' . $q . '%');
}
$stmt = db()->prepare($sql . ' ORDER BY u.id DESC LIMIT 200');
$stmt->execute($params);
$users = $stmt->fetchAll();

$pageTitle = 'Customers';
$active = 'users.php';
require __DIR__ . '/inc/header.php';
?>
<form class="toolbar" method="get">
  <input type="search" name="q" value="<?= h($q) ?>" placeholder="Name, email or phone">
  <button class="btn gray">Search</button>
</form>
<div class="box">
<table>
  <tr><th>Name</th><th>Email</th><th>Phone</th><th>Orders</th><th>Spent</th><th>Joined</th></tr>
  <?php foreach ($users as $u): ?>
  <tr>
    <td><?= h($u['name']) ?></td>
    <td><?= h($u['email']) ?></td>
    <td><?= h($u['phone']) ?></td>
    <td><?= (int) $u['orders'] ?></td>
    <td><?= rupees($u['spent']) ?></td>
    <td class="muted"><?= h(date('d M Y', strtotime($u['created_at']))) ?></td>
  </tr>
  <?php endforeach; ?>
</table>
</div>
<?php require __DIR__ . '/inc/footer.php';

Change the admin password

Terminal + SQL
php -r "echo password_hash('MyNew#Strong2026', PASSWORD_DEFAULT), PHP_EOL;"

-- then in phpMyAdmin → SQL:
UPDATE admins SET password_hash = '$2y$10$...paste-the-hash...' WHERE email = 'admin@karteasy.in';
⚠️ Before going live

Change the password, use HTTPS (free SSL on Hostinger), keep DEBUG off, and never share the admin URL in the app.

Frequently Asked Questions

Why does the admin panel use sessions but the app uses tokens?
The admin panel runs in a browser, where cookies and sessions work automatically and HttpOnly cookies are safe from JavaScript. The app is not a browser, so a Bearer token is simpler there.
Can I give the panel a different URL?
Yes. Rename the admin folder to something hard to guess (for example manage-7k2q). Security does not depend on the name, but it reduces automated login attempts.
How do I add another admin?
Create a hash with password_hash(), then insert a row into the admins table in phpMyAdmin with name, email and that hash.
← E-commerce App Tutorial
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicAPI Security & Testing Your progress: 11 / 28 (39%) · Lesson 12 / 28 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free