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
pdoand a PDO driver:pdo_sqlitefor the default dev/test setup, orpdo_mysql/pdo_pgsqlfor 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 | 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 withpassword_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 withENT_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:
POST /checkoutcreates apendingorder and a Stripe Checkout Session, then redirects the customer to Stripe.- On return,
/checkout/successverifies the session and, if paid, marks the orderpaidand decrements stock. POST /webhook/stripehandlescheckout.session.completedout of band (verified by the webhook signature, so it is exempt from CSRF) and marks the order paid as well;markPaidis 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.