Introduction — Why Build Your Own Platform?
I've been a backend developer for over six years. I've worked with WooCommerce, Shopify, and Magento — and every time, I felt like I was fighting the platform instead of solving the business problem. They're brilliant products, but when your requirements diverge even slightly from the happy path, you end up deep in plugin hell, monkey-patching template overrides, or paying for enterprise tiers you don't need.
So I built SirPhire — a fully custom, production-grade eCommerce platform using PHP 8.3 as its backbone, paired with MySQL 8, Redis, Nginx, vanilla JavaScript, Tailwind CSS, and Stripe for payments. The initial use case was a store selling personalized accessories — things like a customize mobile cover configurator where customers upload artwork and pick device models, or browse ready-made listings like an iPhone 15 cover with multiple finish options. No frameworks. No ORM magic hiding the SQL. Just clean, well-structured code that I understand entirely, can debug at 2 AM, and can extend without reading 400 pages of documentation.
This post is a full, honest deep-dive: architecture decisions, actual code, schema design, payment integration, Docker setup, testing, and deployment. By the end, you should have a complete mental model of how a real PHP eCommerce backend is structured — and enough code snippets to build your own.
Technology Stack (Simple Overview)
- PHP 8.3 → Main backend language (handles all the website logic)
- MySQL 8.0 → Database to store products, orders, users, and inventory
- Redis 7 → Super fast storage for cart, sessions, and caching
- Nginx 1.25 → Web server (handles requests and serves files)
- Tailwind CSS 3.4 → Makes the website look nice and modern
- Vanilla JavaScript (ES2024) → Adds interactive features like cart and galleries
- Stripe API → Handles all payments, refunds, and subscriptions
- Composer → Manages PHP libraries and packages
- Docker → Runs the app in containers (easy to develop and deploy)
- JWT (Firebase) → Secure login system for the API
- PHPUnit 11 → Used for testing the code
- PHPMailer / SES → Sends emails (order confirmations, password resets, etc.)
03
Project Architecture & Folder Structure
SirPhire follows a lightweight MVC pattern. There's no full framework — the router is ~120 lines of PHP, the templating engine is basic include with output buffering, and the database layer is a thin PDO wrapper. This makes the codebase extremely readable by anyone with core PHP knowledge.
# SirPhire Root Structure
sirphire/
├── app/
│ ├── Controllers/
│ │ ├── ProductController.php
│ │ ├── CartController.php
│ │ ├── OrderController.php
│ │ ├── AuthController.php
│ │ └── AdminController.php
│ ├── Models/
│ │ ├── Product.php
│ │ ├── User.php
│ │ ├── Order.php
│ │ ├── Cart.php
│ │ └── Inventory.php
│ ├── Services/
│ │ ├── PaymentService.php
│ │ ├── MailService.php
│ │ ├── CacheService.php
│ │ └── ImageService.php
│ ├── Middleware/
│ │ ├── AuthMiddleware.php
│ │ ├── RateLimiter.php
│ │ └── CsrfMiddleware.php
│ └── Api/
│ ├── ProductApiController.php
│ └── OrderApiController.php
├── core/
│ ├── Router.php
│ ├── Database.php
│ ├── Request.php
│ ├── Response.php
│ ├── Session.php
│ └── View.php
├── config/
│ ├── database.php
│ ├── app.php
│ └── mail.php
├── public/
│ ├── index.php # front controller
│ ├── assets/
│ │ ├── css/
│ │ ├── js/
│ │ └── images/
├── resources/
│ └── views/
│ ├── layouts/
│ ├── storefront/
│ └── admin/
├── database/
│ ├── migrations/
│ └── seeds/
├── tests/
│ ├── Unit/
│ └── Integration/
├── docker/
├── .env
├── composer.json
└── docker-compose.yml
04
Database Design with MySQL 8
The schema is normalized to 3NF for most tables, with strategic denormalization on order_items to preserve a historical snapshot of pricing at the time of purchase. MySQL 8's window functions, CTEs, and JSON column support all get used heavily.
-- Users table with JSONB-like metadata
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE DEFAULT (UUID()),
name VARCHAR(150) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('customer','admin','manager') DEFAULT 'customer',
address JSON,
phone VARCHAR(20),
email_verified_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Products with full-text search support
CREATE TABLE products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sku VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
slug VARCHAR(280) NOT NULL UNIQUE,
description TEXT,
price DECIMAL(10,2) NOT NULL,
sale_price DECIMAL(10,2) NULL,
category_id BIGINT UNSIGNED NOT NULL,
brand VARCHAR(100),
images JSON, -- array of image paths/CDN URLs
attributes JSON, -- {color, size, weight, ...}
stock_qty INT UNSIGNED DEFAULT 0,
status ENUM('active','draft','archived') DEFAULT 'draft',
meta_title VARCHAR(255),
meta_desc VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id),
FULLTEXT INDEX ft_product (name, description),
INDEX idx_slug (slug),
INDEX idx_status_price (status, price)
) ENGINE=InnoDB;
-- Orders with snapshot pricing
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(30) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
status ENUM('pending','paid','processing','shipped','delivered','refunded','cancelled'),
subtotal DECIMAL(10,2) NOT NULL,
discount_total DECIMAL(10,2) DEFAULT 0.00,
shipping_total DECIMAL(10,2) DEFAULT 0.00,
tax_total DECIMAL(10,2) DEFAULT 0.00,
grand_total DECIMAL(10,2) NOT NULL,
currency CHAR(3) DEFAULT 'USD',
shipping_addr JSON,
billing_addr JSON,
stripe_pi_id VARCHAR(100), -- Stripe PaymentIntent ID
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user_status (user_id, status),
INDEX idx_order_number (order_number)
) ENGINE=InnoDB;
CREATE TABLE order_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
product_snapshot JSON NOT NULL, -- name, sku, price at order time
quantity SMALLINT UNSIGNED NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
line_total DECIMAL(10,2) NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
) ENGINE=InnoDB;
05
PHP 8.3 Backend — Core Application
Router
<?php
declare(strict_types=1);
namespace SirPhire\Core;
class Router
{
private array $routes = [];
private array $middlewares = [];
public function get(string $path, array|callable $action): static
{
return $this->addRoute('GET', $path, $action);
}
public function post(string $path, array|callable $action): static
{
return $this->addRoute('POST', $path, $action);
}
public function middleware(string ...$names): static
{
$last = array_key_last($this->routes);
$this->routes[$last]['middlewares'] = $names;
return $this;
}
private function addRoute(string $method, string $path, mixed $action): static
{
$pattern = '#^' . preg_replace('/\{([a-z]+)\}/', '(?P<$1>[^/]+)', $path) . '$#';
$this->routes[] = [
'method' => $method,
'pattern' => $pattern,
'action' => $action,
'middlewares' => [],
];
return $this;
}
public function dispatch(Request $request): Response
{
foreach ($this->routes as $route) {
if ($route['method'] !== $request->method()) continue;
if (! preg_match($route['pattern'], $request->path(), $matches)) continue;
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
$request->setParams($params);
foreach ($route['middlewares'] as $mw) {
app($mw)->handle($request);
}
$action = $route['action'];
if (is_array($action)) {
[$controller, $method] = $action;
return app($controller)->{$method}($request);
}
return $action($request);
}
return Response::notFound();
}
}
Database Layer — PDO Wrapper
namespace SirPhire\Core;
use PDO, PDOStatement, PDOException;
class Database
{
private static ?PDO $pdo = null;
public static function connection(): PDO
{
if (static::$pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
env('DB_HOST'), env('DB_PORT', '3306'), env('DB_NAME')
);
static::$pdo = new PDO($dsn, env('DB_USER'), env('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone='+00:00'",
]);
}
return static::$pdo;
}
public static function run(string $sql, array $params = []): PDOStatement
{
$stmt = static::connection()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public static function transaction(callable $callback): mixed
{
$pdo = static::connection();
$pdo->beginTransaction();
try {
$result = $callback($pdo);
$pdo->commit();
return $result;
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
}
}
06
Authentication System with JWT
SirPhire uses a hybrid auth approach: traditional PHP sessions for the storefront (cookie-based), and JWT tokens for the REST API. The firebase/php-jwt package handles token signing and verification.
namespace SirPhire\Controllers;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use SirPhire\Core\{Database, Request, Response};
use SirPhire\Models\User;
class AuthController
{
public function login(Request $request): Response
{
$email = filter_var($request->input('email'), FILTER_SANITIZE_EMAIL);
$password = $request->input('password');
$user = User::findByEmail($email);
if (! $user || ! password_verify($password, $user['password_hash'])) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
$payload = [
'iss' => env('APP_URL'),
'sub' => $user['uuid'],
'iat' => time(),
'exp' => time() + 3600 * 24,
'role' => $user['role'],
];
$token = JWT::encode($payload, env('JWT_SECRET'), 'HS256');
return Response::json([
'token' => $token,
'user' => [
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
],
]);
}
public function register(Request $request): Response
{
$data = $request->validated([
'name' => 'required|min:2|max:100',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
$userId = User::create([
'name' => $data['name'],
'email' => strtolower($data['email']),
'password_hash' => password_hash($data['password'], PASSWORD_BCRYPT, ['cost' => 12]),
]);
// Dispatch welcome email asynchronously via Redis queue
queue('mail:welcome', ['user_id' => $userId]);
return Response::json(['message' => 'Account created. Please verify your email.'], 201);
}
}
07
Product & Inventory Management
SirPhire's product model is designed to handle both simple and configurable products. In practice, this means a basic listing like an iPhone 15 cover in fixed colors can live alongside a fully configurable customize mobile cover product — where the customer uploads a photo, selects a material (matte, glossy, leather), and picks from device variants. Both are stored in the same products table; the attributes JSON column carries the variant data, and a separate product_customizations table holds uploaded artwork and configuration per order item.
namespace SirPhire\Models;
use SirPhire\Core\Database;
class Product
{
public static function all(array $filters = [], int $page = 1, int $per = 24): array
{
$where = ['p.status = :status'];
$params = ['status' => 'active'];
if (! empty($filters['q'])) {
$where[] = 'MATCH(p.name, p.description) AGAINST (:q IN BOOLEAN MODE)';
$params['q'] = $filters['q'] . '*';
}
if (! empty($filters['category'])) {
$where[] = 'p.category_id = :cat';
$params['cat'] = (int) $filters['category'];
}
if (! empty($filters['max_price'])) {
$where[] = 'p.price <= :max';
$params['max'] = (float) $filters['max_price'];
}
$sql = "
SELECT p.*, c.name AS category_name,
(p.sale_price IS NOT NULL AND p.sale_price < p.price) AS on_sale
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE " . implode(' AND ', $where) . "
ORDER BY p.created_at DESC
LIMIT :limit OFFSET :offset
";
$params['limit'] = $per;
$params['offset'] = ($page - 1) * $per;
return Database::run($sql, $params)->fetchAll();
}
public static function decrementStock(int $productId, int $qty): bool
{
$affected = Database::run(
"UPDATE products SET stock_qty = stock_qty - :qty
WHERE id = :id AND stock_qty >= :qty",
['qty' => $qty, 'id' => $productId]
)->rowCount();
return $affected > 0;
}
}
08
Shopping Cart with Redis Session Storage
The cart is stored in Redis as a JSON-encoded hash. This gives us extremely fast reads/writes and TTL-based expiry for abandoned carts. The predis/predis client is used for the Redis connection.
namespace SirPhire\Models;
use SirPhire\Services\CacheService;
class Cart
{
private string $key;
private const int TTL = 86400 * 7; // 7 days
public function __construct(string $sessionId)
{
$this->key = 'cart:' . $sessionId;
}
public function add(int $productId, int $qty = 1, array $options = []): void
{
$cart = $this->get();
$itemKey = $productId . '_' . md5(serialize($options));
$product = Product::findOrFail($productId);
if (isset($cart[$itemKey])) {
$cart[$itemKey]['qty'] += $qty;
} else {
$cart[$itemKey] = [
'product_id' => $productId,
'name' => $product['name'],
'sku' => $product['sku'],
'price' => $product['sale_price'] ?? $product['price'],
'image' => $product['images'][0] ?? null,
'qty' => $qty,
'options' => $options,
];
}
app(CacheService::class)->set($this->key, json_encode($cart), self::TTL);
}
public function total(): float
{
return array_reduce(
$this->get(),
fn($carry, $item) => $carry + ($item['price'] * $item['qty']),
0.0
);
}
private function get(): array
{
$raw = app(CacheService::class)->get($this->key);
return $raw ? json_decode($raw, true) : [];
}
}
09
09
Checkout & Stripe Payment Integration
SirPhire uses Stripe's Payment Intents API rather than the older Charges API. This gives us full 3D Secure support, better SCA compliance, and a cleaner webhook model. The flow: create a PaymentIntent server-side → confirm with Stripe.js client-side → fulfill via webhook.
namespace SirPhire\Services;
use Stripe\StripeClient;
use Stripe\Exception\{ApiErrorException, SignatureVerificationException};
use SirPhire\Core\Database;
use SirPhire\Models\{Order, Product};
class PaymentService
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(env('STRIPE_SECRET_KEY'));
}
public function createPaymentIntent(array $cartItems, string $currency = 'usd'): array
{
$amount = collect($cartItems)->sum(fn($i) => (int) round($i['price'] * $i['qty'] * 100));
$intent = $this->stripe->paymentIntents->create([
'amount' => $amount,
'currency' => $currency,
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'cart_hash' => md5(serialize($cartItems)),
],
]);
return [
'client_secret' => $intent->client_secret,
'payment_intent_id' => $intent->id,
];
}
public function handleWebhook(string $payload, string $sigHeader): void
{
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sigHeader, env('STRIPE_WEBHOOK_SECRET')
);
} catch (SignatureVerificationException) {
throw new \RuntimeException('Invalid Stripe webhook signature', 400);
}
match ($event->type) {
'payment_intent.succeeded' => $this->onPaymentSucceeded($event->data->object),
'payment_intent.failed' => $this->onPaymentFailed($event->data->object),
'charge.refunded' => $this->onRefunded($event->data->object),
default => null,
};
}
private function onPaymentSucceeded($intent): void
{
Database::transaction(function() use ($intent) {
$order = Order::findByStripeId($intent->id);
Order::updateStatus($order['id'], 'paid');
foreach ($order['items'] as $item) {
Product::decrementStock($item['product_id'], $item['quantity']);
}
queue('mail:order_confirmation', ['order_id' => $order['id']]);
});
}
}
Stripe.js — Client-Side Payment Form
<div id="payment-form">
<div id="payment-element"></div>
<button id="submit-btn" class="btn-primary w-full mt-6">
<span id="btn-text">Pay $<?= number_format($total, 2) ?></span>
<span id="spinner" class="hidden">Processing...</span>
</button>
<div id="payment-error" class="text-red-500 mt-3 hidden"></div>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('');
const elements = stripe.elements({
clientSecret: '',
appearance: { theme: 'night', variables: { colorPrimary: '#f4a732' } }
});
const paymentEl = elements.create('payment');
paymentEl.mount('#payment-element');
document.getElementById('submit-btn').addEventListener('click', async (e) => {
e.preventDefault();
document.getElementById('spinner').classList.remove('hidden');
document.getElementById('btn-text').classList.add('hidden');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: '' }
});
if (error) {
document.getElementById('payment-error').textContent = error.message;
document.getElementById('payment-error').classList.remove('hidden');
document.getElementById('spinner').classList.add('hidden');
document.getElementById('btn-text').classList.remove('hidden');
}
});
</script>
10
REST API Layer
namespace SirPhire\Api;
use SirPhire\Core\{Request, Response};
use SirPhire\Models\Product;
use SirPhire\Services\CacheService;
class ProductApiController
{
public function index(Request $request): Response
{
$cacheKey = 'api:products:' . md5(serialize($request->query()));
$cached = app(CacheService::class)->get($cacheKey);
if ($cached) {
return Response::json(json_decode($cached, true))
->header('X-Cache', 'HIT');
}
$products = Product::all(
filters: $request->query(),
page: (int) $request->query('page', 1),
per: (int) $request->query('per_page', 24),
);
$response = [
'data' => $products,
'meta' => [
'total' => Product::count($request->query()),
'page' => (int) $request->query('page', 1),
'per_page' => (int) $request->query('per_page', 24),
],
];
app(CacheService::class)->set($cacheKey, json_encode($response), 300);
return Response::json($response)->header('X-Cache', 'MISS');
}
}
11
Frontend — HTML5, Tailwind CSS & Vanilla JS
<article class="group relative bg-neutral-900 rounded-xl overflow-hidden border border-white/5 hover:border-amber-400/40 transition-all duration-300">
<a href="/products/" class="block aspect-square overflow-hidden">
<img
src=""
alt=""
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<?php if ($product['on_sale']): ?>
<span class="absolute top-3 left-3 bg-amber-400 text-black text-xs font-bold px-2 py-1 rounded">SALE</span>
<?php endif; ?>
</a>
<div class="p-4">
<p class="text-xs text-neutral-500 uppercase tracking-wider mb-1"></p>
<h3 class="text-white font-medium text-sm truncate mb-3"></h3>
<div class="flex items-center justify-between">
<div>
<?php if ($product['on_sale']): ?>
<span class="text-amber-400 font-bold">$</span>
<span class="text-neutral-600 line-through text-xs ml-1">$</span>
<?php else: ?>
<span class="text-white font-bold">$</span>
<?php endif; ?>
</div>
<button
class="add-to-cart bg-amber-400 hover:bg-amber-300 text-black text-xs font-bold px-3 py-1.5 rounded-lg transition-colors"
data-product-id=""
>Add to Cart</button>
</div>
</div>
</article>
Cart JavaScript — AJAX Add to Cart
// Cart module — vanilla JS, no dependencies
const Cart = (() => {
const ENDPOINT = '/api/cart';
let state = { items: [], count: 0, total: 0 };
const updateUI = () => {
document.querySelectorAll('[data-cart-count]')
.forEach(el => el.textContent = state.count);
document.querySelectorAll('[data-cart-total]')
.forEach(el => el.textContent = `$${state.total.toFixed(2)}`);
};
const addItem = async (productId, qty = 1, options = {}) => {
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content,
},
body: JSON.stringify({ product_id: productId, qty, options })
});
if (!res.ok) throw new Error('Cart update failed');
const data = await res.json();
state = data.cart;
updateUI();
showToast('Added to cart ✓', 'success');
} catch (err) {
showToast(err.message, 'error');
}
};
const showToast = (msg, type) => {
const toast = document.createElement('div');
toast.className = `fixed bottom-6 right-6 px-4 py-3 rounded-lg text-sm font-medium z-50
${type === 'success' ? 'bg-amber-400 text-black' : 'bg-red-500 text-white'}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
};
// Delegate click events for dynamic content
document.addEventListener('click', e => {
const btn = e.target.closest('[data-product-id]');
if (btn?.classList.contains('add-to-cart')) {
e.preventDefault();
addItem(btn.dataset.productId);
}
});
return { addItem, state };
})();
window.Cart = Cart;
12
Nginx Server Configuration
server {
listen 443 ssl http2;
server_name sirphire.com www.sirphire.com;
ssl_certificate /etc/letsencrypt/live/sirphire.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sirphire.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_session_cache shared:SSL:10m;
root /var/www/sirphire/public;
index index.php;
# Gzip compression
gzip on;
gzip_comp_level 5;
gzip_types text/css application/javascript application/json image/svg+xml;
# Cache static assets at the edge
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff2|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()";
# PHP-FPM
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php-fpm:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
# Block access to sensitive files
location ~ /\.(env|git|htaccess) {
deny all;
}
}
13
Docker & Docker Compose Setup
version: '3.9'
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./public:/var/www/sirphire/public:ro
- ./docker/nginx/sirphire.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/ssl:/etc/letsencrypt:ro
depends_on:
- php-fpm
networks:
- app
php-fpm:
build:
context: .
dockerfile: docker/php/Dockerfile
environment:
- APP_ENV=production
- DB_HOST=mysql
- REDIS_HOST=redis
volumes:
- .:/var/www/sirphire
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: sirphire
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- mysql_data:/var/lib/mysql
- ./database/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
networks:
- app
worker:
build:
context: .
dockerfile: docker/php/Dockerfile
command: php artisan queue:work redis --sleep=3 --tries=3
depends_on:
- redis
networks:
- app
volumes:
mysql_data:
redis_data:
networks:
app:
driver: bridge
14
Testing with PHPUnit 11
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use SirPhire\Models\Cart;
use Mockery;
class CartTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->cache = Mockery::mock(CacheService::class);
}
public function test_adds_item_to_empty_cart(): void
{
$this->cache->shouldReceive('get')->andReturn(null);
$this->cache->shouldReceive('set')->once();
$cart = new Cart('test-session-id');
$cart->add($productId = 42, $qty = 2);
$this->assertSame(2, $cart->count());
}
public function test_total_reflects_sale_price(): void
{
// Given a product with sale_price=8.99, regular=14.99
$cart = $this->cartWithFixture(['price' => 14.99, 'sale_price' => 8.99, 'qty' => 3]);
$this->assertEquals(26.97, round($cart->total(), 2));
}
public function test_removes_item_correctly(): void
{
$cart = $this->cartWithTwoItems();
$cart->remove('item_key_1');
$this->assertCount(1, $cart->items());
}
protected function tearDown(): void
{
Mockery::close();
}
}
15
Security Hardening
Security is not an afterthought in SirPhire — it's baked into every layer. Here's a summary of the protections implemented:
namespace SirPhire\Middleware;
use SirPhire\Core\{Request, Response};
use SirPhire\Services\CacheService;
class RateLimiter
{
public function __construct(
private readonly CacheService $cache,
private readonly int $maxAttempts = 60,
private readonly int $windowSeconds = 60,
) {}
public function handle(Request $request): void
{
$key = 'rl:' . sha1($request->ip() . $request->path());
$count = $this->cache->incr($key);
if ($count === 1) {
$this->cache->expire($key, $this->windowSeconds);
}
if ($count > $this->maxAttempts) {
Response::json([
'error' => 'Too many requests',
'retry_after' => $this->windowSeconds,
], 429)->send();
exit();
}
}
}
Top comments (0)