
Full-featured PHP e-commerce platform — storefront + admin, self-hosted on Docker
Modern frameworks hide the machinery of a real store — sessions, password hashing, cart state, coupon math, order lifecycles, file uploads, deployment — behind conventions. I wanted to build a complete e-commerce experience end-to-end on a classic PHP + MySQL stack, server-rendered and self-hosted, so every one of those moving parts was something I'd designed and wired by hand rather than inherited from a framework. The goal was a production-shaped app with two full surfaces — a customer storefront and an admin panel — not a tutorial toy.
The app is intentionally "flat": top-level .php pages render HTML, while the browser hydrates
products, cart, and wishlist through AJAX against JSON endpoints under assets/api/. The frontend is
Bootstrap 5 + jQuery and vanilla ES6; the backend is PHP 8.2 talking to MySQL 8 over mysqli. Shared
concerns live in a small core — config.php for environment and paths, and an includes/security.php
helper that centralises hashing, sessions, CSRF, and rate limiting.
Auth was the part I most wanted to get right. Passwords are hashed with Argon2id (tuned to 64 MB / 4 / 3) with a bcrypt fallback, and legacy hashes transparently upgrade themselves on the next successful login:
// includes/security.php — strong hash on write, silent upgrade on read
public static function hash_password(string $plain): string {
if (defined('PASSWORD_ARGON2ID')) {
return password_hash($plain, PASSWORD_ARGON2ID, [
'memory_cost' => 64 * 1024, // 64 MB
'time_cost' => 4,
'threads' => 3,
]);
}
return password_hash($plain, PASSWORD_BCRYPT);
}
public static function verify_password(string $plain, string $hash): bool {
if (!password_verify($plain, $hash)) return false;
// Migrate older bcrypt hashes to Argon2id once we have the plaintext.
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
self::upgrade_hash($plain);
}
return true;
}Sessions are fingerprinted against the user agent, regenerate their ID periodically, and idle out after
30 minutes. On top of that, config.php resolves its own base URL and path at runtime — auto-detecting
localhost vs. a subdomain, honouring X-Forwarded-Proto behind the proxy, and reconstructing the
project root from DOCUMENT_ROOT/SCRIPT_NAME — so the same codebase runs unchanged in a subdirectory
locally and at a domain root in production.
It ships as a multi-container Docker stack: Nginx → php-fpm → MySQL, with phpMyAdmin bound to
loopback for DB admin, fronted by an existing Nginx Proxy Manager for TLS. Nginx also adds
defense-in-depth around uploads — any non-image dropped into an upload directory is returned a 403
before php-fpm ever executes it, so a smuggled script can't run.
The result is a fully working storefront — browse and filter products, cart and wishlist with localStorage sync, coupons, checkout, order history, and returns, plus account and profile management — paired with an admin panel for product CRUD, order editing, users, and coupon management. It runs self-hosted behind a reverse proxy at ecommerce.kunjdeveloper.me, with persistent upload volumes and a documented Docker runbook. Building it from the ground up turned the fundamentals of a real store — secure auth, session hardening, AJAX-driven state, and containerised deployment — from things I'd used into things I understand.