Production-quality Laravel demo for a GGSel-style digital goods shop.
Engineering focus: exactly-once key delivery, idempotent payment webhooks, safe behavior under concurrent requests, recovery after stock/provider failures, and promo-code usage limits under concurrency.
Stack: Laravel 11 · PHP 8.3 · PostgreSQL 16 · Blade · vanilla JS · Vite · Docker
- Tech stack
- Project structure
- Prerequisites
- Installation
- Environment variables
- Running the application
- Manual purchase flow (UI)
- API reference
- Order lifecycle
- Seed data
- Admin & recovery
- Running tests
- Race reproduction scripts
- Architecture — exactly-once delivery
- Troubleshooting
- Submission checklist
| Layer | Technology |
|---|---|
| Backend | PHP 8.2+, Laravel 11 |
| Database | PostgreSQL 14+ (Docker image: 16-alpine) |
| Frontend | HTML, CSS, vanilla JavaScript, Blade templates |
| Build | Vite 6, laravel-vite-plugin |
| Tests | PHPUnit 11 |
| Containers | Docker, Docker Compose |
| Queue | sync (no Redis/RabbitMQ) |
No React/Vue/Angular. No real payment gateway — payment is simulated via webhooks.
app/
Console/Commands/ # e.g. app:test-payment-race
Enums/ # OrderStatus, ProductType, …
Http/Controllers/ # Storefront, orders, webhooks, admin, mock providers
Services/
Orders/ # CreateOrder, OrderStateMachine
Payments/ # ProcessPaymentWebhook, ReconcilePendingPaymentEvents
Delivery/ # DeliverOrder, AllocateInventoryKey, IssueViaProvider
Promo/ # ApplyPromoCode, ReservePromoCode
database/
migrations/ # PostgreSQL schema, unique indexes, partial indexes
seeders/ # Products, 50 CS2 keys, promo codes
resources/
views/ # storefront, orders/show, admin/recovery
css/ js/ # Vite entrypoints
routes/web.php # Web UI + JSON API under /api/*
scripts/ # Shell scripts to reproduce race conditions
tests/Feature/ # Concurrency & idempotency tests
docker-compose.yml # postgres + app (php artisan serve)
Recommended (Docker):
- Docker & Docker Compose
- Node.js 18+ and npm (for frontend build)
curl,jqorphpCLI (for race scripts)
Alternative (local PHP):
- PHP 8.2+ with
pdo_pgsql - Composer 2
- PostgreSQL 14+
- Node.js 18+ and npm
From the project root:
# 1. PHP dependencies (can also run inside container — see below)
composer install
# 2. Environment
cp .env.example .env
# 3. Generate app key (required — empty APP_KEY causes 500 errors)
docker compose run --rm app php artisan key:generate
# Copy the generated APP_KEY into .env if the command prints it there
# 4. Start PostgreSQL
docker compose up -d postgres
# 5. Wait until Postgres is healthy, then migrate + seed
docker compose run --rm app php artisan migrate:fresh --seed
# 6. Frontend assets
npm install
npm run build
# 7. Start the app
docker compose up appNote: Inside Docker,
DB_HOST=postgresandDB_PORT=5432are set indocker-compose.yml.
On the host machine (localphp artisan), useDB_HOST=127.0.0.1andDB_PORT=5433.
If you don't have Composer on the host:
docker compose run --rm app composer installcomposer install
cp .env.example .env
php artisan key:generate
# Point .env at Docker Postgres on host port 5433:
# DB_HOST=127.0.0.1
# DB_PORT=5433
# DB_DATABASE=test_shop
# DB_USERNAME=test_shop
# DB_PASSWORD=secret
docker compose up -d postgres
php artisan migrate:fresh --seed
npm install && npm run build
php artisan serve
# → http://127.0.0.1:8000docker compose exec app php artisan migrate:fresh --seed| Variable | Default | Description |
|---|---|---|
APP_KEY |
— | Required. Laravel encryption key. Run php artisan key:generate. |
APP_URL |
http://localhost:8000 |
Base URL for links and provider callbacks |
DB_CONNECTION |
pgsql |
Database driver |
DB_HOST |
postgres (Docker) / 127.0.0.1 (host) |
PostgreSQL host |
DB_PORT |
5432 (Docker) / 5433 (host) |
PostgreSQL port |
DB_DATABASE |
test_shop |
Database name |
DB_USERNAME |
test_shop |
Database user |
DB_PASSWORD |
secret |
Database password |
ADMIN_TOKEN |
dev-admin-token |
Bearer token for /api/admin/* |
QUEUE_CONNECTION |
sync |
Jobs run inline (no worker needed) |
PROVIDER_A_URL |
mock provider A endpoint | Used for provider-type products |
PROVIDER_B_URL |
mock provider B endpoint | Fallback provider |
PROVIDER_A_SUCCESS_RATE |
100 |
% success (mock) |
PROVIDER_A_SERVER_ERROR_RATE |
0 |
% HTTP 500 |
PROVIDER_A_TIMEOUT_RATE |
0 |
% HTTP 504 timeout |
PROVIDER_A_DELAY_MS |
0 |
Artificial delay before response |
PROVIDER_B_* |
same pattern | Provider B tuning |
Money: all amounts are whole rubles (RUB), not kopecks.
Example webhook amount for CS2 Prime: "amount": 1290
docker compose up app # foreground, logs in terminal
docker compose up -d app # backgrounddocker compose down| Change | Command |
|---|---|
| PHP / Blade | Usually auto-reloads (volume mount). Restart if needed: docker compose restart app |
| JS / CSS | npm run build (or npm run dev for hot reload) |
.env / config |
docker compose up -d --force-recreate app |
| Migrations | docker compose exec app php artisan migrate |
npm run devRun the Laravel app separately (docker compose up app). Vite serves assets from port 5173.
This is the full path a reviewer can click through without curl.
You'll see: header (Каталог, search), hero carousel (auto-rotates every 5s), service icons, Steam top-up widget, and 5 product cards.
Click Купить on any product card.
- Creates order via
POST /api/orders - Redirects to
/orders/{public_id}
Product types:
| Type | Examples | Delivery |
|---|---|---|
inventory |
KEY-CS2-PRIME, KEY-GTA5, KEY-EFT |
Key from inventory_keys table |
provider |
STEAM-TOPUP-500, subscriptions, gift cards |
Code from mock provider API |
Only
KEY-CS2-PRIMEhas 50 seeded keys. Other inventory SKUs will reachout_of_stockafter payment unless you add keys via admin API.
On the order page:
| Button | Effect |
|---|---|
| Симулировать оплату | Sends paid webhook → triggers delivery |
| Симулировать ошибку оплаты | Sends failed webhook → order becomes payment_failed |
The simulator calls the same ProcessPaymentWebhook service as a real payment gateway would.
After successful payment:
- Inventory product: key appears in the Ключ field (e.g.
LFXC-TNCS-BPCD) - Provider product: generated code appears (e.g.
ABCD-EFGH-IJKL)
Status should become delivered.
Promo codes work when creating an order via API:
curl -X POST http://localhost:8000/api/orders \
-H "Content-Type: application/json" \
-H "Idempotency-Key: promo-test-1" \
-d '{"sku":"KEY-CS2-PRIME","promo_code":"GG500"}'| Code | Type | Effect | Max uses |
|---|---|---|---|
WELCOME10 |
10% off | — | 100 |
GG500 |
Fixed | −500 ₽ | 20 |
LIMIT3 |
25% off | — | 3 (concurrency test) |
ONCEONLY |
50% off | — | 1 |
All JSON API routes live under /api/* and are excluded from CSRF verification.
POST /api/orders
Content-Type: application/json
Idempotency-Key: <unique-uuid>
Accept: application/json
{
"sku": "KEY-CS2-PRIME",
"promo_code": null
}Response 201:
{
"data": {
"id": "ord_abc123",
"status": "created",
"product": {
"sku": "KEY-CS2-PRIME",
"name": "...",
"type": "inventory"
},
"amounts": {
"original": 1290,
"discount": 0,
"final": 1290,
"currency": "RUB"
},
"payment": { "paid_at": null, "is_paid": false },
"delivery": { "status": "created", "code": null, "recoverable": false }
}
}Idempotency: repeating the same Idempotency-Key returns the same order (no duplicate rows).
GET /api/orders/{public_id}
Accept: application/jsonPOST /api/webhooks/payment
Content-Type: application/json
{
"event_id": "evt_unique_001",
"order_id": "ord_abc123",
"status": "paid",
"amount": 1290,
"currency": "RUB",
"created_at": "2025-01-01T12:00:00Z"
}| Field | Rules |
|---|---|
event_id |
Unique per webhook event. Duplicates are ignored (idempotent). |
order_id |
Order public_id |
status |
paid or failed |
amount |
Must match order final_amount exactly |
currency |
Must match order currency (RUB) |
If order doesn't exist yet: event is stored as pending_order and reconciled when the order is created.
Same as webhook, but generates event_id automatically:
POST /api/payments/simulate/{public_id}
Content-Type: application/json
{ "status": "paid" }Or { "status": "failed" }.
GET /api/admin/orders/recovery
Authorization: Bearer dev-admin-token
Accept: application/jsonReturns orders in: paid, delivering, out_of_stock, delivery_failed.
POST /api/admin/orders/{public_id}/retry-delivery
Authorization: Bearer dev-admin-tokenRe-runs delivery for recoverable orders (after stock replenishment or provider recovery).
POST /api/admin/inventory
Authorization: Bearer dev-admin-token
Content-Type: application/json
{
"sku": "KEY-GTA5",
"codes": ["GTA5-KEY-001", "GTA5-KEY-002"]
}POST /api/mock/providers/a/issue
Content-Type: application/json
{
"request_id": "req_ord_abc-1",
"sku": "STEAM-TOPUP-500",
"order_id": "ord_abc123"
}Configure behavior via PROVIDER_A_* env vars.
created ──paid webhook──► paid ──delivery──► delivering ──success──► delivered
│ │
│ └── out_of_stock / delivery_failed (recoverable)
│
└── failed webhook ──► payment_failed (terminal)
| Status | Meaning |
|---|---|
created |
Order placed, awaiting payment |
paid |
Payment confirmed, delivery pending or in progress |
delivering |
Delivery attempt in progress |
delivered |
Key/code issued (terminal success) |
payment_failed |
Payment rejected (terminal) |
out_of_stock |
No inventory keys left — recoverable via admin retry after restock |
delivery_failed |
Provider error — recoverable via admin retry |
| SKU | Name | Type | Price (₽) |
|---|---|---|---|
STEAM-TOPUP-500 |
Пополнение Steam 500 ₽ | provider | 500 |
STEAM-TOPUP-1000 |
Пополнение Steam 1000 ₽ | provider | 1000 |
STEAM-TOPUP-2500 |
Пополнение Steam 2500 ₽ | provider | 2500 |
KEY-CS2-PRIME |
CS2 Prime Status ключ | inventory | 1290 |
KEY-GTA5 |
GTA V ключ активации | inventory | 1990 |
KEY-EFT |
Escape from Tarkov ключ | inventory | 3490 |
| … | subscriptions & gift cards | provider | 299–1490 |
- 50 keys seeded for
KEY-CS2-PRIMEonly (e.g.LFXC-TNCS-BPCD,P3EI-W8UO-9B4K, …)
See Promo codes above.
http://localhost:8000/admin/recovery
Lists orders needing attention (paid, delivering, out_of_stock, delivery_failed).
- Customer pays for
KEY-GTA5(no keys in seed) - Order becomes
out_of_stock - Admin adds keys:
curl -X POST http://localhost:8000/api/admin/inventory \
-H "Authorization: Bearer dev-admin-token" \
-H "Content-Type: application/json" \
-d '{"sku":"KEY-GTA5","codes":["GTA5-RECOVER-001"]}'- Admin retries delivery:
curl -X POST http://localhost:8000/api/admin/orders/ord_xxx/retry-delivery \
-H "Authorization: Bearer dev-admin-token"- Order →
deliveredwith one key
docker compose exec postgres psql -U test_shop -d test_shop \
-c "CREATE DATABASE test_shop_test;" 2>/dev/null || trueTests use test_shop_test (see phpunit.xml).
docker compose exec app php artisan test
# or
docker compose run --rm app vendor/bin/phpunit| Test | Scenario |
|---|---|
Duplicate event_id |
Same webhook processed once |
Multiple event_ids |
Many paid events → one delivery |
| Webhook before order | Event stored, reconciled on order create |
| Empty inventory | out_of_stock → retry after restock |
| Provider timeout | Same request_id → same code on retry |
Promo LIMIT3 |
≤ 3 successful uses under concurrency |
| Idempotency key | Same key → same order |
Prerequisite: app running at http://localhost:8000
Scripts need jq or php on the host for JSON parsing (scripts/lib.sh).
chmod +x scripts/*.sh # if needed| Script | What it proves |
|---|---|
./scripts/test-payment-race.sh |
50 parallel paid webhooks → 1 delivery, 1 key |
./scripts/test-duplicate-webhook.sh |
Same event_id repeated → no extra delivery |
./scripts/test-webhook-before-order.sh |
Webhook arrives before order → reconciled later |
./scripts/test-empty-stock-recovery.sh |
Empty pool → restock → retry delivers once |
./scripts/test-retry-race.sh |
Parallel admin retries → still one key |
./scripts/test-provider-timeout.sh |
Provider timeout → retry returns same code |
./scripts/test-promo-race.sh |
LIMIT3 promo under parallel order creation |
docker compose exec app php artisan app:test-payment-race --concurrency=50Optional: pass an existing order public id as first argument.
{
"data": {
"status": "delivered",
"delivery": { "code": "LFXC-TNCS-BPCD" }
}
}Only one inventory key allocated per order.
- Payment dedup —
payment_events.event_idUNIQUE; duplicate webhooks are no-ops (PostgreSQL savepoints handle insert races). - Order lock —
SELECT … FOR UPDATEon order row beforecreated → paid; only one worker triggers delivery. - Key allocation —
FOR UPDATE SKIP LOCKEDon available keys + partial unique index oninventory_keys(order_id). - Provider idempotency —
provider_requests (provider, request_id)UNIQUE; response persisted before timeout is returned. - Timeout ≠ failure — retries reuse the same
request_id, never mint a second code. - Recovery —
out_of_stock/delivery_failed→ admin retry re-entersDeliverOrder. - Promo limits — promo row lock +
promo_code_usagesinsert in the same transaction as order creation.
Short answer for reviewers: duplicate webhooks hit a unique event_id constraint; concurrent paid events lock the order row so only one path triggers delivery; key selection uses SKIP LOCKED inside a transaction; provider retries reuse the same request_id with a durable stored response. No invariant relies on PHP-only checks.
APP_KEY is empty in .env.
docker compose run --rm app php artisan key:generate
docker compose up -d --force-recreate app| Context | Fix |
|---|---|
| App in Docker | DB_HOST=postgres, DB_PORT=5432 (set in docker-compose.yml) |
| PHP on host | DB_HOST=127.0.0.1, DB_PORT=5433 |
| Postgres not running | docker compose up -d postgres |
Database not seeded:
docker compose exec app php artisan migrate:fresh --seednpm install && npm run buildAPI routes under /api/* must be CSRF-exempt (configured in bootstrap/app.php). Restart app after changing bootstrap files.
- Ensure app is running:
curl http://localhost:8000/up - Install
jq:sudo apt install jq - Or install PHP CLI on the host
Mock providers are called internally (not via HTTP) when the provider URL contains /api/mock/providers/. No extra setup needed.
Docker sets PHP_CLI_SERVER_WORKERS=1 to avoid multi-worker .env reload issues. Don't increase without understanding the impact.
| Deliverable | Location |
|---|---|
| Live demo / run instructions | This README → Installation, Manual purchase flow |
| Source code | Repository root |
| Race reproduction | Race reproduction scripts |
| Exactly-once explanation | Architecture |
| Time spent | ~8–10 hours (scaffold, backend concurrency, storefront, tests, scripts) |
- 50 parallel
paidwebhooks → one delivery, one key - Same
event_idrepeated → no additional effect - Webhook before order → stored and reconciled once
- Empty pool →
out_of_stock, retry after replenish → one key - Promo
LIMIT3under parallel requests → ≤ 3 successful uses
For a public URL: build assets (
npm run build), deploypublic/to Netlify/Vercel, and point API calls to your backend. Local Docker demo is sufficient for evaluation.