Building a shopping cart is the “Hello World” of e-commerce, but building one that is secure, scalable, and maintainable is a different beast entirely.
In the landscape of modern web development (circa 2025), we often rely heavily on frameworks like Laravel or Symfony. However, understanding the underlying mechanisms of session management and state persistence is crucial for any senior developer. Whether you are debugging a complex issue in a legacy monolith or building a lightweight microservice where a full framework is overkill, these fundamentals are your best friends.
In this guide, we are going to engineer a pure PHP Object-Oriented shopping cart. We aren’t just pushing arrays into $_SESSION; we are building a structured, secure system using PHP 8.2+ features like readonly classes and typed properties.
What You Will Learn #
- Session Security: Preventing session fixation and hijacking.
- OOP Architecture: separating the Cart logic from the storage mechanism.
- State Management: Handling product persistence efficiently.
- PHP 8.x Features: Utilizing modern syntax for cleaner code.
1. Prerequisites and Environment #
Before we dive into the code, ensure your environment is ready. We are aiming for a modern stack.
- PHP 8.2 or higher (We will use readonly classes and type hints).
- Composer (Optional for autoloading, but we’ll stick to native requires for simplicity in this tutorial).
- Local Server: Docker, XAMPP, or the built-in PHP server (
php -S localhost:8000).
No external database is required for this specific tutorial; we will simulate a product catalog to keep the focus strictly on Session and Cart logic.
2. Architecture and Data Flow #
A common mistake in junior code is mixing session handling directly inside the controller or the view. We will decouple these concerns.
Here is the flow of data when a user adds an item to the cart:
We need three core components:
- SessionManager: A wrapper to handle
$_SESSIONsecurely. - Product: A Data Transfer Object (DTO) representing an item.
- Cart: The logic engine (Add, Remove, Calculate Total).
3. Step-by-Step Implementation #
Step 1: The Secure Session Wrapper #
Directly accessing $_SESSION superglobals throughout your application makes testing difficult and security audits a nightmare. Let’s create a wrapper class.
Create a file named SessionManager.php.
<?php
// SessionManager.php
class SessionManager {
public function __construct() {
if (session_status() === PHP_SESSION_NONE) {
// Secure session settings before starting
ini_set('session.use_only_cookies', 1);
ini_set('session.use_strict_mode', 1);
session_set_cookie_params([
'lifetime' => 3600, // 1 hour
'path' => '/',
'domain' => 'localhost', // Adjust for production
'secure' => true, // Requires HTTPS
'httponly' => true, // Prevents JS access
'samesite' => 'Strict'
]);
session_start();
}
}
/**
* Regenerate ID to prevent Session Fixation
*/
public function regenerate(): void {
session_regenerate_id(true);
}
public function set(string $key, mixed $value): void {
$_SESSION[$key] = $value;
}
public function get(string $key, mixed $default = null): mixed {
return $_SESSION[$key] ?? $default;
}
public function remove(string $key): void {
unset($_SESSION[$key]);
}
public function destroy(): void {
session_destroy();
$_SESSION = [];
}
}Why this matters: Notice the session_set_cookie_params. Setting httponly to true is critical to prevent XSS attacks from stealing session tokens.
Step 2: The Product DTO #
Using PHP 8.2’s readonly class feature, we can create immutable product objects. This ensures that once a product is defined in our cart context, it cannot be accidentally modified.
Create Product.php.
<?php
// Product.php
readonly class Product {
public function __construct(
public int $id,
public string $name,
public float $price,
public string $image
) {}
}Step 3: The Cart Logic #
This is the brain of the operation. The Cart class shouldn’t care how data is stored, only that it is stored. We inject the SessionManager via dependency injection.
Create Cart.php.
<?php
// Cart.php
require_once 'SessionManager.php';
require_once 'Product.php';
class Cart {
private array $items = [];
private const SESSION_KEY = 'cart_inventory';
public function __construct(private SessionManager $session) {
$this->items = $this->session->get(self::SESSION_KEY, []);
}
/**
* Add product to cart or update quantity if exists
*/
public function add(Product $product, int $quantity = 1): void {
if (isset($this->items[$product->id])) {
$this->items[$product->id]['quantity'] += $quantity;
} else {
$this->items[$product->id] = [
'product' => $product,
'quantity' => $quantity
];
}
$this->persist();
}
public function remove(int $productId): void {
if (isset($this->items[$productId])) {
unset($this->items[$productId]);
$this->persist();
}
}
public function updateQuantity(int $productId, int $quantity): void {
if (isset($this->items[$productId]) && $quantity > 0) {
$this->items[$productId]['quantity'] = $quantity;
$this->persist();
} elseif ($quantity === 0) {
$this->remove($productId);
}
}
public function getItems(): array {
return $this->items;
}
public function isEmpty(): bool {
return empty($this->items);
}
public function getTotal(): float {
$total = 0.0;
foreach ($this->items as $item) {
$total += $item['product']->price * $item['quantity'];
}
return $total;
}
public function clear(): void {
$this->items = [];
$this->session->remove(self::SESSION_KEY);
}
private function persist(): void {
$this->session->set(self::SESSION_KEY, $this->items);
}
}Step 4: Tying it Together (The Controller/View) #
Now, let’s create a simple index.php to simulate the storefront and the cart interactions. In a real MVC app, these would be separate files.
<?php
// index.php
require_once 'SessionManager.php';
require_once 'Cart.php';
require_once 'Product.php';
// 1. Initialize dependencies
$sessionManager = new SessionManager();
$cart = new Cart($sessionManager);
// 2. Mock Database
$products = [
1 => new Product(1, 'Ergonomic Keyboard', 120.00, 'keyboard.jpg'),
2 => new Product(2, 'Gaming Mouse', 55.50, 'mouse.jpg'),
3 => new Product(3, '4K Monitor', 300.00, 'monitor.jpg'),
];
// 3. Handle Actions (Controller Logic)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF Check should go here in production
if (isset($_POST['add'])) {
$id = (int)$_POST['product_id'];
if (isset($products[$id])) {
$cart->add($products[$id], 1);
}
}
if (isset($_POST['remove'])) {
$cart->remove((int)$_POST['product_id']);
}
if (isset($_POST['clear'])) {
$cart->clear();
}
// Prevent form resubmission
header("Location: index.php");
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PHP Cart System</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 30px; }
.product, .cart-item { border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 8px; }
.btn { background: #007bff; color: white; border: none; padding: 8px 15px; cursor: pointer; border-radius: 4px; }
.btn-danger { background: #dc3545; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<h1>DevPro Shop</h1>
<div class="grid">
<!-- Product List -->
<div>
<h2>Products</h2>
<?php foreach ($products as $product): ?>
<div class="product">
<h3><?= htmlspecialchars($product->name) ?></h3>
<p>$<?= number_format($product->price, 2) ?></p>
<form method="POST">
<input type="hidden" name="product_id" value="<?= $product->id ?>">
<button type="submit" name="add" class="btn">Add to Cart</button>
</form>
</div>
<?php endforeach; ?>
</div>
<!-- Cart View -->
<div>
<h2>Your Cart</h2>
<?php if ($cart->isEmpty()): ?>
<p>Your cart is empty.</p>
<?php else: ?>
<table>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Price</th>
<th>Action</th>
</tr>
<?php foreach ($cart->getItems() as $id => $item): ?>
<tr>
<td><?= htmlspecialchars($item['product']->name) ?></td>
<td><?= $item['quantity'] ?></td>
<td>$<?= number_format($item['product']->price * $item['quantity'], 2) ?></td>
<td>
<form method="POST" style="display:inline;">
<input type="hidden" name="product_id" value="<?= $id ?>">
<button type="submit" name="remove" class="btn btn-danger" style="padding: 4px 8px; font-size: 12px;">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td colspan="2"><strong>Total</strong></td>
<td colspan="2"><strong>$<?= number_format($cart->getTotal(), 2) ?></strong></td>
</tr>
</table>
<br>
<form method="POST">
<button type="submit" name="clear" class="btn btn-danger">Clear Cart</button>
</form>
<?php endif; ?>
</div>
</div>
</body>
</html>
4. Storage Strategies: Session vs. The World #
We utilized PHP’s native sessions for this implementation. However, as you scale, you might wonder if this is the best approach. Let’s compare the options.
| Storage Method | Persistence | Scalability | Complexity | Best Use Case |
|---|---|---|---|---|
| Native PHP Session | Server Filesystem | Low (requires sticky sessions) | Low | Single server, small to medium apps |
| Database (SQL) | High | High | Medium | Shopping carts needing long persistence (days/weeks) |
| Redis / Memcached | In-Memory | High | Medium | High-traffic apps, volatile carts |
| Client-side Cookies | Browser | High | High (Security risks) | Non-sensitive data, very small payloads |
Expert Tip: For a production e-commerce site in 2025, the industry standard is often a hybrid approach.
- Use Redis as the session handler for speed.
- If the user is logged in, sync the cart to a SQL Database. This allows them to build a cart on mobile and checkout on desktop.
5. Performance and Pitfalls #
The “Serialization” Trap #
When you store objects in $_SESSION, PHP serializes them. If your Product object contains a connection to a database or a massive description text, you are bloating the session file.
- Solution: Store only the Product ID and Quantity in the session. Re-fetch product details from the database when rendering the cart view. Our example stored the object for simplicity, but in high-volume apps, store IDs only.
Session Locking #
PHP creates a lock on the session file when a script starts. If a user opens 5 tabs of your store, requests 2 through 5 will wait until request 1 finishes.
- Solution: Call
session_write_close()as soon as you are done writing to the session, usually before you start rendering heavy HTML or making API calls.
Security: Session Fixation #
An attacker might trick a user into clicking a link with a pre-defined session ID.
- Solution: We implemented
session_regenerate_id(true)in our wrapper. Call this method whenever a user creates a cart, logs in, or enters sensitive checkout flows.
6. Conclusion #
Building a cart system isn’t just about array manipulation; it’s about managing state securely and consistently. By wrapping PHP’s native session functionality in a dedicated SessionManager and using strict typing with DTOs, we’ve created a cart that is far more robust than the typical procedural spaghetti code.
Where to go next? To make this production-ready:
- Implement a Database Interface to swap out the simulated product array.
- Add Redis as the session save handler in your
php.ini. - Implement CSRF protection for the add/remove forms.
Happy coding, and keep your carts secure!