< Back to projects

Web app · case study

CarPart

Car-parts store with row-locked checkout so stock can't oversell, manually verified payments, and live order notifications.

CarPart E-Commerce Platform preview
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

Architecture diagramCloudflare 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.CLOUDFLAREVPS · DOCKER COMPOSEBrowserBlade + Alpine.jsLaravel EchoCloudflare proxyTLS terminates hereproxied DNSCaddyreverse proxycontainer networkLaravel 12PHP 8.4 containerrow-locked checkoutRedissessions, cachejob queueMySQLcatalogue, stock, orders39 migrationsQueue workeraccount and orderemailsSchedulerreverts demo sessionsevery minutePusher Channelsmanaged WebSocketsHTTPSorigin:8080FOR UPDATEsessions, jobsbroadcastjobsrevertWebSocket · private user.{id}
Dev · applicationOps · infrastructureExternal · third partyoptional or pushedScroll sideways to see all of it.
  • 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

  1. PHPUnit

    199 tests on MySQL 8.4; nothing builds

push → main

  1. PHPUnit

    199 tests on MySQL 8.4, must pass

  2. Docker build

    in GitHub Actions

  3. ghcr.io

    GitHub registry, tagged by commit

  4. SSH deploy

    pull image, recreate containers

  5. 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.