Web app · case study
CarPart
Car-parts store with row-locked checkout so stock can't oversell, manually verified payments, and live order notifications.

- My role
Sole developer
- Type
Web application
- Stack
Laravel, MySQL, Redis, Docker, Tailwind CSS
- Runs on
Self-hosted VPS: Docker Compose behind Caddy
- Edge
Cloudflare, proxied
Why I built it
I built CarPart for a database course, to practise designing a normalized relational schema behind a real application: a car-parts store with a storefront and an admin panel.I'm the sole developer.The hard part is keeping checkout correct when customers compete for the same stock.I run it myself as a Docker stack on a VPS behind Cloudflare.Every push is tested and deployed by CI, and the public demo undoes every visitor's changes within 30 minutes.
It's a student project with no real transactions, so checkout uses a bank-transfer proof that an admin verifies instead of a payment gateway.
The problem
Practise normalized relational design on a realistic domain: products, variants, stock, orders, payments, refunds and coupons.
Keep stock and coupon limits correct when checkouts happen at the same time.
Let anyone try the admin panel without leaving a mess behind.
Features
- Customizable admin setup: branding, currency, payments, products
- Demo accounts:
- Admin - admin@carpart.com / adminEco1234#
- Customer - user@carpart.com / userEco1234#
Architecture
- Browser to Cloudflare proxy: HTTPS
- Cloudflare proxy to Caddy: origin
- Caddy to Laravel 12: :8080
- Laravel 12 to MySQL: FOR UPDATE
- Laravel 12 to Redis: sessions, jobs
- Laravel 12 to Pusher Channels: broadcast
- Queue worker to Redis: jobs
- Scheduler to MySQL: revert
- Browser to Pusher Channels: WebSocket · private user.{id}
In words
Cloudflare proxies traffic and terminates TLS.The origin is my VPS, running the app as a Docker Compose stack behind Caddy.Laravel runs checkout in transactions with row locks, so stock can't oversell.MySQL and Redis run in the same stack.A queue worker sends account and order emails from Redis, and a scheduler reverts expired demo sessions every minute.Pusher pushes order and payment notifications to a private per-user channel over a WebSocket.
Pipeline
pull request
PHPUnit
199 tests on MySQL 8.4; nothing builds
push → main
PHPUnit
199 tests on MySQL 8.4, must pass
Docker build
in GitHub Actions
ghcr.io
GitHub registry, tagged by commit
SSH deploy
pull image, recreate containers
Health check
container healthy, smoke test via Caddy
Decisions
- Pessimistic row locks at checkout.
Stock, orders and coupon usage are read with SELECT … FOR UPDATE in a transaction.A short wait beats selling the last item twice.
- An explicit order state machine.
A transition table defines every allowed status change.Each change is logged to a history table.
- Transfer proofs instead of a payment gateway.
No real money moves.An admin verifies each proof.A SHA-256 hash rejects duplicate uploads.
- Build the image in CI, not on the server.
Compiling PHP extensions and front-end assets on a small VPS competed with the live app for CPU and memory.Now the server only pulls a finished image.
- Broadcasts skip the queue; emails use it.
Order alerts go out immediately.Account and order emails wait in a Redis queue for a worker, so a slow mail send never delays a page.
- Pusher in production, Reverb ready.
The client connects to either.Production uses managed Pusher; self-hosted Laravel Reverb only needs its keys at build time.
Challenges
- Parallel checkouts could over-redeem a limited coupon.
The usage row is locked in the transaction.A test covers concurrent use.
- A public demo with a real admin panel.
Every write in a demo session is recorded, then replayed in reverse when the session ends.A sweep every minute catches any session that didn't revert.
- Slow admin pages as data grew.
Cached the hot-path queries, fixed N+1 queries, and added a missing index.
- Putting the tests in CI exposed a broken migration.
On a fresh database, a migration dropped an index it assumed was a duplicate of a unique index that never existed, so every database test failed.Fixed the migration, and updated 36 tests that had fallen behind the app, without removing any assertions.
Testing
199 PHPUnit tests in 37 files cover orders, coupons, inventory, refunds, cart, notifications and auth.
CI runs them against MySQL 8.4 on every push and pull request.Nothing deploys unless they pass.
Each deploy then waits for the container health check and smoke-tests the site through Caddy, and fails if either misses.
Outcome
Live demo with admin and customer accounts; each session's changes are undone within 30 minutes.
Every push to main is tested, then deploys itself.
Next
Roll back automatically when the health check fails
Deploy without dropping requests (blue/green)
Stack
- Laravel
Serves the storefront, admin panel, and order APIs.
- MySQL
Stores the product catalogue, stock levels, and orders.
- Redis
Backs sessions, the cache, and the email job queue.
- Docker
Runs the app, queue worker, scheduler, MySQL and Redis as one Compose stack on the VPS.
- Tailwind CSS
Styles the storefront and admin layouts.
Security
- Authentication
Laravel sessions with required email verification.Login and account routes are rate-limited.
- Input validation
Laravel FormRequest rules on every input.
- Protections
Row locks prevent overselling.SHA-256 hashes reject duplicate payment proofs.
Operations
- Runs on
Self-hosted VPS: Docker Compose behind Caddy
- Edge
Cloudflare, proxied
- Deploy
Push to main.GitHub Actions runs the 199 tests against MySQL 8.4, then builds the image, pushes it to GitHub's container registry, and deploys over SSH.Nothing deploys unless the tests pass, and the deploy waits for a health check and a smoke test through Caddy.
- Health check
I run this box end to end, from SSH hardening to the containers.Every image is tagged with its commit, so a bad release can be rolled back by redeploying an earlier tag.