Files
2026-08-28 23:07:30 +02:00

5.9 KiB

Bazaar

A small e-commerce shop built on a hand-rolled PHP microframework - no Laravel, no Symfony, no external framework at all. Catalog, session cart, Stripe Checkout (test mode), customer accounts and a full admin back office, all on top of a router, a PDO layer that only ever runs prepared statements, SQL migrations, hardened sessions, CSRF protection and output escaping written from scratch.

Requirements

  • PHP 8.3 with pdo and a PDO driver:
    • pdo_sqlite for the default dev/test setup, or
    • pdo_mysql / pdo_pgsql for a server profile.
  • Composer.

Quick start

composer install
cp .env.example .env
php bin/migrate.php      # create the schema
php bin/seed.php         # load demo catalog + accounts
php -S 127.0.0.1:8000 -t public

Open http://127.0.0.1:8000.

Seeded accounts:

Role Email Password
Admin admin@bazaar.test adminpass123
Customer customer@bazaar.test password123

The admin panel lives at /admin and is gated behind the admin role.

Tests

vendor/bin/phpunit

Covers the router, the session cart, the order lifecycle (pending -> paid -> fulfilled with stock decrement), authentication and CSRF. Order and auth tests run against an in-memory SQLite database built from the real migrations.

Architecture

Everything a request touches is hand-written under src/.

public/index.php        Front controller - the single entry point.
src/Core/
  App.php               Boots services, wires the Context, dispatches the request.
  Router.php            Method + path matching, {param} capture, per-route middleware.
  RouteMatch.php        Result of a match (handler, params, middleware, status).
  Request.php           Immutable wrapper over the incoming request.
  Response.php          Status, headers and body; html/json/redirect helpers.
  Context.php           Explicit container passed to every controller.
  Database.php          PDO wrapper - prepared statements only, transactions.
  Migrator.php          Runs .sql migrations, tracks them in a migrations table.
  Session.php           Hardened session (httponly, samesite, strict mode) + flash.
  Csrf.php              Per-session token, constant-time verification.
  View.php              Plain-PHP view engine with a layout and partials.
  Config.php            Reads .env, exposes typed config.
src/Routes.php          The route table.
src/Controllers/        HTTP controllers (storefront + Admin/*).
src/Repositories/       One class per table, all queries parameterized.
src/Services/           Auth, Cart, OrderService, StripeGateway, domain exceptions.
src/Support/helpers.php  e(), formatMoney(), slugify(), config(), basePath().
views/                  Layouts, partials and page templates (escaped output).
database/migrations/    Versioned SQL schema.
bin/                migrate.php, seed.php.
tests/                  PHPUnit suite.

Request lifecycle: public/index.php -> App::boot() builds the services and shares view data -> App::run() matches the route, verifies CSRF on every mutating request (except the Stripe webhook, which is verified by signature instead), runs the route middleware (guest / auth / admin), instantiates the controller and returns a Response.

Security

  • Every SQL statement goes through PDO::prepare + bound parameters. There is no string interpolation of user values into SQL anywhere.
  • CSRF tokens are required on all POST forms and checked centrally in the front controller.
  • Passwords are stored with password_hash (bcrypt) and checked with password_verify.
  • Sessions are httponly, SameSite=Lax, use strict mode, and the id is regenerated on login and logout.
  • All template output is escaped through e() (htmlspecialchars with ENT_QUOTES).

Database schema

users

  • id, email (unique), passwordHash, name, isAdmin, createdAt.

categories

  • id, name, slug (unique), description.

products

  • id, categoryId (FK -> categories, set null on delete), name, slug (unique), description, priceCents, stockQuantity, imageUrl, isActive, createdAt.

orders

  • id, userId (FK -> users), status (pending | paid | fulfilled | cancelled), totalCents, currency, stripeSessionId, createdAt, paidAt, fulfilledAt, cancelledAt.

orderItems

  • id, orderId (FK -> orders, cascade), productId (FK -> products, set null), productName, unitPriceCents, quantity. Name and price are snapshotted at order time so historical orders stay correct if the product later changes.

Money is stored as integer cents everywhere to avoid floating point rounding.

Stripe keys

Payments run in Stripe test mode. Put your test keys in .env:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Get them from https://dashboard.stripe.com/test/apikeys. While the placeholders are in place the checkout page renders with a clear "payments disabled" notice and the pay button is disabled, so the rest of the shop is fully usable without any keys.

Order flow with real test keys:

  1. POST /checkout creates a pending order and a Stripe Checkout Session, then redirects the customer to Stripe.
  2. On return, /checkout/success verifies the session and, if paid, marks the order paid and decrements stock.
  3. POST /webhook/stripe handles checkout.session.completed out of band (verified by the webhook signature, so it is exempt from CSRF) and marks the order paid as well; markPaid is idempotent so the two paths never double-count.

Point a Stripe webhook (or stripe listen --forward-to) at /webhook/stripe to test the async path.

Configuration

.env keys: APP_ENV, APP_DEBUG, APP_URL, CURRENCY, the DB_* block (DB_DRIVER = sqlite | mysql | pgsql), and the STRIPE_* block. Defaults live in src/Core/Config.php.