DEV Community

Govind Jangra
Govind Jangra

Posted on

How I Built SirPhire — A Production-Grade PHP eCommerce Platform from Scratch

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)

  1. PHP 8.3 → Main backend language (handles all the website logic)
  2. MySQL 8.0 → Database to store products, orders, users, and inventory
  3. Redis 7 → Super fast storage for cart, sessions, and caching
  4. Nginx 1.25 → Web server (handles requests and serves files)
  5. Tailwind CSS 3.4 → Makes the website look nice and modern
  6. Vanilla JavaScript (ES2024) → Adds interactive features like cart and galleries
  7. Stripe API → Handles all payments, refunds, and subscriptions
  8. Composer → Manages PHP libraries and packages
  9. Docker → Runs the app in containers (easy to develop and deploy)
  10. JWT (Firebase) → Secure login system for the API
  11. PHPUnit 11 → Used for testing the code
  12. 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']]);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)