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

149 lines
5.9 KiB
Markdown

# 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
```bash
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
```bash
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`.