Trong một website thương mại điện tử, giỏ hàng (shopping cart) là xương sống của trải nghiệm mua sắm. Người dùng duyệt sản phẩm, thêm vào giỏ, điều chỉnh số lượng, áp mã giảm giá, rồi tiến hành thanh toán. Để tối ưu tốc độ và giảm phụ thuộc vào cơ sở dữ liệu trong giai đoạn đầu, ta có thể hiện thực giỏ hàng hoàn toàn bằng PHP + SESSION—nhanh, gọn, dễ mở rộng.
Nếu bạn đang xây dựng nền tảng bán hàng, các bài viết nền tảng sau sẽ rất hữu ích để ghép nối thành hệ thống hoàn chỉnh:
-
Kết nối MySQL bằng PDO trong PHP – để lấy danh sách sản phẩm, giá, tồn kho.
-
Làm CRUD trong PHP thuần – để quản trị sản phẩm, danh mục, giá.
-
Giữ đăng nhập bằng Session trong PHP – để gắn giỏ hàng với tài khoản người dùng.
-
Tạo phân trang với LIMIT & OFFSET – để hiển thị danh sách sản phẩm mượt mà.
-
Tạo form đăng nhập có validate – để đảm bảo dữ liệu hợp lệ khi checkout.
-
Gửi email bằng PHPMailer – để gửi email xác nhận đơn hàng.
-
Hash mật khẩu bằng password_hash/password_verify – để bảo mật tài khoản.
Trong bài này, ta sẽ thiết kế kiến trúc giỏ hàng tối giản nhưng đúng chuẩn: cấu trúc dữ liệu, hành động thêm/sửa/xóa, tính tổng tiền, lưu Session, và sườn “checkout” sẵn sàng tích hợp thanh toán sau này.
Kiến trúc và dữ liệu giỏ hàng
Để giỏ hàng vận hành đơn giản, ta định nghĩa cấu trúc dữ liệu trong $_SESSION['cart'] ở dạng mảng kết hợp. Mỗi mục giỏ hàng chỉ nên lưu các thuộc tính cần thiết cho quá trình tính tiền và hiển thị: id, name, price, qty, và có thể thêm image, sku, options (màu, size). Việc “tối thiểu hoá” giúp payload Session nhẹ và giảm lỗi đồng bộ nếu giá hoặc tồn kho đổi—khi checkout ta sẽ re-validate từ DB.
Đối với định danh sản phẩm, bạn nên dùng int hoặc uuid duy nhất. Giá (price) nên là số nguyên (ví dụ lưu giá theo đơn vị đồng không có thập phân) để tránh sai số do số thực. Khi hiển thị có thể định dạng về 1.250.000đ. Còn tồn kho, thuế, phí vận chuyển, voucher/mã giảm giá, ta có thể thêm ở bước 2 nâng cấp.
Khởi tạo Session và helper giỏ hàng
Mọi thao tác giỏ hàng đều cần khởi tạo session. Nên đặt một file cart_lib.php chứa các hàm helper (add/update/remove/get). Điều này giúp bạn tái sử dụng và unit-test dễ dàng, thay vì viết rải rác trong controller hoặc view.
Trước khi viết code, hãy lưu ý hai nguyên tắc:
-
Bất biến dữ liệu: Khi “thêm” cùng một sản phẩm, ta tăng
qtythay vì tạo dòng mới. Điều này giúp giỏ hàng gọn. -
Phòng thủ: Luôn ép kiểu và ràng buộc
qty≥ 1; nếuqtyvề 0 thì xóa item; nếupriceâm hoặc không hợp lệ thì từ chối.
<?php
// cart_lib.php
session_start();
function cart_init(): void {
if (!isset($_SESSION['cart'])) {
$_SESSION['cart'] = [
'items' => [], // key: product_id, value: ['id','name','price','qty','image'...]
'total_qty' => 0,
'total_amount' => 0,
];
}
}
function cart_recalc(): void {
$totalQty = 0;
$totalAmount = 0;
foreach ($_SESSION['cart']['items'] as $it) {
$totalQty += $it['qty'];
$totalAmount += $it['price'] * $it['qty'];
}
$_SESSION['cart']['total_qty'] = $totalQty;
$_SESSION['cart']['total_amount'] = $totalAmount;
}
Đoạn mã trên khởi tạo cấu trúc giỏ hàng và hàm cart_recalc() để cập nhật tổng số lượng và tổng tiền. Việc tách riêng recalculation giúp bạn gọi lại sau mọi thao tác thay đổi dữ liệu, đảm bảo tính nhất quán.
Hàm thêm sản phẩm vào giỏ (add)
Chức năng thêm vào giỏ cần xử lý hai tình huống: (1) sản phẩm chưa có trong giỏ → tạo mới; (2) sản phẩm đã có → cộng dồn qty. Bạn nên ép kiểu đầu vào và validate số lượng tối thiểu, đồng thời chặn các trường hợp phá Session (ví dụ qty âm).
Bên cạnh đó, để bảo mật, bạn không nên tin tưởng hoàn toàn giá gửi từ client. Ở bản tối giản demo ta cứ nhận, nhưng trong production bạn nên lấy giá từ DB bằng PDO (xem lại bài PDO) mỗi lần add/update, đặc biệt trước khi checkout để tránh “price tampering”.
<?php
// cart_lib.php (tiếp)
function cart_add(int $id, string $name, int $price, int $qty = 1, string $image = ''): void {
cart_init();
$qty = max(1, (int)$qty);
$price = max(0, (int)$price);
if (isset($_SESSION['cart']['items'][$id])) {
$_SESSION['cart']['items'][$id]['qty'] += $qty;
} else {
$_SESSION['cart']['items'][$id] = [
'id' => $id,
'name' => $name,
'price' => $price,
'qty' => $qty,
'image' => $image,
];
}
cart_recalc();
}
Hàm trên đủ linh hoạt để gọi từ nhiều nơi (trang danh sách, trang chi tiết). Khi tích hợp thật, hãy load lại giá từ DB theo id trước khi gọi cart_add().
Hàm cập nhật số lượng (update)
Người dùng có thể thay đổi số lượng trong giỏ (ví dụ từ 1 lên 3). Ở đây cần bảo vệ: nếu qty < 1 thì xóa; nếu qty quá lớn thì có thể giới hạn theo tồn kho. Số lượng nên là số nguyên dương hợp lệ để tránh lỗi logic hoặc tấn công.
Trong UI, việc cập nhật thường đến từ form POST. Bạn nên bổ sung CSRF token để hạn chế tấn công giả mạo yêu cầu—chi tiết cơ chế token có thể tận dụng từ bài Tạo form đăng nhập có validate.
<?php
// cart_lib.php (tiếp)
function cart_update(int $id, int $qty): void {
cart_init();
if (!isset($_SESSION['cart']['items'][$id])) return;
$qty = (int)$qty;
if ($qty <= 0) {
unset($_SESSION['cart']['items'][$id]);
} else {
$_SESSION['cart']['items'][$id]['qty'] = $qty;
}
cart_recalc();
}
Với cart_update(), sau mỗi lần thay đổi, ta luôn gọi cart_recalc() để tổng số lượng và tổng tiền khớp với trạng thái hiện tại.
Hàm xóa một dòng hoặc xóa toàn bộ giỏ
Xóa là thao tác đơn giản nhưng cần nhất quán với tổng giỏ. Ngoài xóa từng sản phẩm, người dùng thường muốn “Xóa tất cả” để làm sạch giỏ hàng—đặc biệt hữu ích khi test hoặc đổi ý hàng loạt.
Đừng quên cân nhắc UI/UX: nên có xác nhận (confirm) trước khi xóa tất cả. Trong thực tế, một số shop còn lưu giỏ ở DB để người dùng quay lại vẫn thấy (persistent cart), nhưng phiên bản Session-only là đủ cho MVP.
<?php
// cart_lib.php (tiếp)
function cart_remove(int $id): void {
cart_init();
if (isset($_SESSION['cart']['items'][$id])) {
unset($_SESSION['cart']['items'][$id]);
cart_recalc();
}
}
function cart_clear(): void {
$_SESSION['cart'] = [
'items' => [],
'total_qty' => 0,
'total_amount' => 0,
];
}
Hai hàm trên đáp ứng cả nhu cầu xóa một dòng và xóa toàn bộ. Nên cung cấp cả hai trên giao diện để người dùng kiểm soát dễ dàng.
Hiển thị giỏ hàng: bảng, tổng tiền, nút hành động
Trang giỏ hàng thường là một bảng có cột ảnh, tên, giá, số lượng, thành tiền, và nút xóa/cập nhật. Khi hiển thị giá, hãy dùng định dạng tiền tệ để dễ đọc; lượng hóa mọi thứ theo số nguyên ở backend và chỉ format khi render view.
Về UX, nên có nút “Tiếp tục mua sắm” (back to listing), “Cập nhật giỏ” (submit form số lượng), và “Thanh toán” (đi tới checkout). Bạn cũng có thể nhúng mã giảm giá ở đây; tuy nhiên, xử lý voucher nên làm ở server-side, có lưu log để tránh lạm dụng.
<?php
// cart.php
require 'cart_lib.php';
cart_init();
function fmt_money(int $v): string {
return number_format($v, 0, ',', '.') . 'đ';
}
?>
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<title>Giỏ hàng</title>
<style>
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ddd;padding:8px}
th{text-align:left;background:#f7f7f7}
.text-right{text-align:right}
</style>
</head>
<body>
<h1>Giỏ hàng</h1>
<form method="post" action="cart_update_handler.php">
<table>
<thead>
<tr>
<th>Sản phẩm</th>
<th>Giá</th>
<th>Số lượng</th>
<th class="text-right">Thành tiền</th>
<th>Hành động</th>
</tr>
</thead>
<tbody>
<?php if (empty($_SESSION['cart']['items'])): ?>
<tr><td colspan="5">Giỏ hàng trống.</td></tr>
<?php else: ?>
<?php foreach ($_SESSION['cart']['items'] as $it): ?>
<tr>
<td>
<?php if (!empty($it['image'])): ?>
<img src="<?= htmlspecialchars($it['image']) ?>" alt="" width="50" style="vertical-align:middle;margin-right:8px">
<?php endif; ?>
<?= htmlspecialchars($it['name']) ?>
</td>
<td><?= fmt_money($it['price']) ?></td>
<td>
<input type="number" name="qty[<?= (int)$it['id'] ?>]" value="<?= (int)$it['qty'] ?>" min="0" style="width:80px">
</td>
<td class="text-right"><?= fmt_money($it['price'] * $it['qty']) ?></td>
<td>
<a href="cart_action.php?action=remove&id=<?= (int)$it['id'] ?>">Xóa</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<tfoot>
<tr>
<th colspan="2">Tổng số lượng</th>
<td><?= (int)$_SESSION['cart']['total_qty'] ?></td>
<th class="text-right" colspan="2"><?= fmt_money($_SESSION['cart']['total_amount']) ?></th>
</tr>
</tfoot>
</table>
<p>
<a href="products.php">← Tiếp tục mua sắm</a>
<button type="submit">Cập nhật giỏ</button>
<a href="checkout.php">Thanh toán →</a>
<a href="cart_action.php?action=clear" style="float:right;color:#c00">Xóa toàn bộ</a>
</p>
</form>
</body>
</html>
Ở ví dụ trên, form gửi về cart_update_handler.php để cập nhật hàng loạt số lượng. Xóa một dòng hoặc xóa toàn bộ được tách qua cart_action.php nhằm đơn nhiệm và rõ ràng URL.
Xử lý các hành động giỏ hàng qua endpoint
Để kiến trúc sạch, ta gom các hành động add/remove/clear vào một endpoint duy nhất. Endpoints nhận tham số qua GET/POST, sau đó gọi helper tương ứng. Khi phát triển thực tế, bạn nên thêm CSRF token và check referer để giảm rủi ro.
Ta cũng cần một handler riêng cho “cập nhật hàng loạt” vì form gửi lên nhiều input qty[id]. Điều này giúp hạn chế vòng lặp nhiều request.
<?php
// cart_action.php
require 'cart_lib.php';
cart_init();
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
switch ($action) {
case 'add':
// Sản phẩm demo – trong thực tế nên lấy từ DB theo $id
$demo = [
'id' => $id,
'name' => $_GET['name'] ?? 'Sản phẩm ' . $id,
'price' => isset($_GET['price']) ? (int)$_GET['price'] : 100000,
'image' => $_GET['image'] ?? '',
];
cart_add($demo['id'], $demo['name'], $demo['price'], 1, $demo['image']);
header('Location: cart.php');
break;
case 'remove':
if ($id > 0) cart_remove($id);
header('Location: cart.php');
break;
case 'clear':
cart_clear();
header('Location: cart.php');
break;
default:
header('Location: cart.php');
}
Endpoint trên minh họa cách gom hành động. Ở môi trường production, tuyệt đối không lấy price từ query string; phải load từ DB và kiểm tra tồn kho trước khi add/update.
Cập nhật số lượng hàng loạt từ form
Khi người dùng nhấn Cập nhật giỏ, form sẽ gửi mảng qty[id] => value. Ta cần vòng lặp qua mảng này và gọi cart_update() cho từng sản phẩm. Lưu ý kiểm tra kiểu dữ liệu và sanitize để tránh ghi bậy Session.
Ngoài ra, nếu có giới hạn tồn kho, hãy chặn và báo lỗi khi qty vượt quá, hoặc clamp (cắt về max). Bạn có thể set $_SESSION['flash'] để hiển thị thông báo trên cart.php.
<?php
// cart_update_handler.php
require 'cart_lib.php';
cart_init();
if (!empty($_POST['qty']) && is_array($_POST['qty'])) {
foreach ($_POST['qty'] as $id => $qty) {
$id = (int)$id;
$qty = (int)$qty;
cart_update($id, $qty);
}
}
header('Location: cart.php');
Với handler này, ta tối ưu được UX: người dùng sửa nhiều dòng rồi cập nhật một lần, thay vì bấm từng nút lẻ tẻ.
Trang danh sách sản phẩm demo và nút “Thêm vào giỏ”
Dù giỏ hàng có thể hoạt động độc lập, trải nghiệm sẽ trọn vẹn hơn khi bạn có trang sản phẩm để thử nhanh. Ở bản mẫu, ta giả lập danh sách sản phẩm cứng; ở bản thật, hãy lấy từ DB bằng PDO, phân trang bằng LIMIT/OFFSET, và lọc nâng cao như trong bài tìm kiếm nâng cao.
Hãy chú ý nút “Thêm vào giỏ” điều hướng tới cart_action.php?action=add&id=.... Bạn có thể chuyển sang AJAX để mượt hơn, nhưng dạng link vẫn đủ cho MVP.

