E-commerce platform built with Laravel and React.
- Backend: Laravel 12, MySQL, Sanctum (Bearer tokens)
- Frontend: React, Vite (planned)
LR--Shop/
├── backend/ # REST API
└── frontend/ # Storefront (planned)
Implemented:
- Categories API (read-only)
- Products API (read-only)
- Guest cart API (
X-Cart-Token) - Auth API (register, email verify, resend verify, login, logout, forgot/reset/change password)
- User carts (
user_id) and guest cart merge on login/register - Orders API (checkout, list, show — auth required; shipping from profile)
- Order confirmation email on checkout (Mailpit locally)
- Order status change email to customer when admin updates status (Mailpit locally)
- CORS configured via
FRONTEND_URLenv variable (defaulthttp://localhost:5173) - Profile API (GET/PATCH/DELETE — hard delete; admins blocked)
- E.164 phone validation on profile and checkout
- Clear cart (
DELETE /cart) - User roles (
customer/admin) and admin middleware - User
is_activeflag; inactive users cannot log in - Admin uploads, category CRUD, product CRUD, order status, user management, and shop settings
- Product stock: cart cannot exceed stock; checkout decrements; cancel/fail/refund restores
- Category, product, and cart seed data
- API Resources for JSON responses
- Route model binding by slug
- Product filtering, search, sort, pagination
- Clean JSON 404 responses for API routes
Planned:
- Stripe test payments
Base URL: http://localhost:8000/api/v1
| Method | Endpoint | Description |
|---|---|---|
| GET | /categories |
List all categories |
| GET | /categories/{slug} |
Single category |
Category fields: id, name, slug, description, image, products_count
| Method | Endpoint | Description |
|---|---|---|
| GET | /products |
List active products (paginated) |
| GET | /products/{slug} |
Single product |
Product fields: id, name, slug, description, price, stock, image, is_active, created_at, updated_at, category
Query parameters (index only):
| Param | Example | Description |
|---|---|---|
category |
laptops |
Filter by category slug |
search |
phone |
Search in name and description |
sort |
price |
Sort field: name, price, created_at (default: name) |
order |
desc |
Sort direction: asc or desc (default: asc) |
per_page |
20 |
Items per page, 1–50 (default: 10) |
Examples:
GET /api/v1/products
GET /api/v1/products?category=laptops
GET /api/v1/products?search=illo&sort=price&order=desc&per_page=20
GET /api/v1/products/macbook-pro
GET /api/v1/categories/phones
Sanctum Bearer tokens. Login returns the token in data.token. Send it on protected routes:
Authorization: Bearer {token}
Accept: application/json
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /register |
no | Create account, send verification email (no token) |
| GET | /email/verify/{id}/{hash} |
no | Confirm email via signed link from mail (no token) |
| POST | /email/verification-notification |
no | Resend verification link ({ "email": "..." }) |
| POST | /login |
no | Return token (unverified or inactive → 403) |
| POST | /logout |
yes | Revoke current token |
| POST | /forgot-password |
no | Send reset link (Mailpit locally) |
| POST | /reset-password |
no | Set new password using email token |
| POST | /change-password |
yes | Change password while logged in |
Email verification: POST /register creates the user with email_verified_at = null and sends a signed link (Mailpit locally). Open the full URL from the mail (browser or GET in Postman). Success: "Email verified." — still no token. Then POST /login. Unverified login → 403 Please verify your email first. Inactive account (is_active = false) → 403 Your account is not active. Please contact support. Invalid credentials still 401. The verify URL includes expires and signature; do not build it by hand.
Logged-in users only (auth:sanctum).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /profile |
yes | Current user (includes role and shipping fields) |
| PATCH | /profile |
yes | Partial update (sometimes fields) |
| DELETE | /profile |
yes | Hard delete account (204); admin → 403 |
DELETE /profile: removes the user, Sanctum tokens, cart, and orders (DB cascade). Admin accounts cannot be deleted this way.
Profile fields: id, name, email, role, phone, shipping_address, city, state, zip, country, timestamps. token only on login.
Phone: optional. If sent, E.164 (+ and 8–15 digits, e.g. +381641234567). Spaces and dashes are stripped. Invalid format → 422. Register does not collect phone.
PATCH /profile body (send only what you change):
{
"name": "Ana",
"phone": "+381641234567",
"shipping_address": "Bulevar 1",
"city": "Beograd",
"state": "Srbija",
"zip": "11000",
"country": "RS"
}Register body:
{
"name": "Ana",
"email": "ana@example.com",
"password": "Secret1!",
"password_confirmation": "Secret1!"
}Password must be at least 8 characters, with mixed case, a number, and a symbol.
Login body:
{
"email": "ana@example.com",
"password": "Secret1!"
}Forgot password body:
{
"email": "ana@example.com"
}Always returns the same message (does not reveal if the email exists). Local mail: Mailpit at http://127.0.0.1:8025 (MAIL_MAILER=smtp, MAIL_HOST=127.0.0.1, MAIL_PORT=1025). Reset link uses FRONTEND_URL (default http://localhost:5173).
Reset password body (token and email from the mail link):
{
"email": "ana@example.com",
"token": "<from mail>",
"password": "NovaPass1!",
"password_confirmation": "NovaPass1!"
}Change password body:
{
"current_password": "Secret1!",
"new_password": "NovaPass1!",
"new_password_confirmation": "NovaPass1!"
}Cart routes are public. Who owns the cart depends on headers:
| Client | How cart is resolved |
|---|---|
| Guest | X-Cart-Token header (UUID) |
| Logged in | Authorization: Bearer {token} → cart with that user_id |
The cart token is returned in the response header X-Cart-Token (not in the JSON body).
| Method | Endpoint | Description |
|---|---|---|
| GET | /cart |
Get current cart (creates one if missing) |
| DELETE | /cart |
Clear all items (204); cart row stays |
| POST | /cart/items |
Add product (or increase quantity if already in cart) |
| PATCH | /cart/items/{id} |
Set item quantity |
| DELETE | /cart/items/{id} |
Remove item (204 No Content) |
Guest headers:
X-Cart-Token: <uuid>
Accept: application/json
Content-Type: application/json
Logged-in headers:
Authorization: Bearer {token}
Accept: application/json
Content-Type: application/json
While logged in, X-Cart-Token is ignored for resolving the cart.
POST /cart/items body:
{
"product_id": 1,
"quantity": 1
}PATCH /cart/items/{id} body:
{
"quantity": 3
}POST and PATCH return 422 (Not enough stock.) if the quantity is greater than the product's stock. For POST, that includes quantity already in the cart.
Cart response fields: id, items, total
Cart item fields: id, quantity, product (id, name, slug, price, image), subtotal
Guest flow:
GET /cart(no Bearer) → readX-Cart-Tokenfrom response headersPOST /cart/itemswith that tokenGET /cartagain to see items and total
Merge on login/register:
Send the guest X-Cart-Token on POST /login or POST /register. Guest items move to the user cart (same product → quantities are summed). The guest cart is deleted. After logout, the user cart stays on the account; guest starts empty (or with a new token).
Seed cart token (optional, for guest testing):
00000000-0000-4000-8000-000000000001
Logged-in users only (auth:sanctum). Guest checkout is not supported — login/register first (cart merge applies). Checkout builds the order from the user cart, then clears cart items.
| Method | Endpoint | Description |
|---|---|---|
| POST | /orders |
Place order from current cart |
| GET | /orders |
List my orders (summary) |
| GET | /orders/{id} |
Order detail (own orders only) |
Shipping is taken from the user profile when the body is empty. Body fields override profile. Incomplete profile (missing phone/address) → 422. customer_phone must be E.164 (same as profile); spaces/dashes are stripped.
POST /orders body (optional if profile is complete):
{
"customer_name": "Ana Anic",
"customer_phone": "+381641234567",
"shipping_address": "Bulevar 1",
"city": "Beograd",
"state": "Srbija",
"zip": "11000",
"country": "RS"
}List (GET /orders) fields: id, status, total, items_count, created_at
Detail / create response fields: id, status, total, address fields, items (product_id, product_name, price, quantity, subtotal), timestamps
Successful create also returns "message": "Order placed successfully." and status 201. Empty cart → 422. Not enough stock → 422; product stock is decremented inside a transaction (lockForUpdate). An order confirmation email is sent to the user's address (Mailpit locally) with order number, total, items, and shipping address.
Requires Authorization: Bearer {token} and role = admin (auth:sanctum + admin). Base path: /api/v1/admin.
Shared image upload for admin resources. Returns a storage path to save on category/product as image.
| Method | Endpoint | Description |
|---|---|---|
| POST | /uploads |
Upload image (201); multipart form-data only |
form-data fields:
| Field | Required | Description |
|---|---|---|
file |
yes | Image (jpeg, png, jpg, webp), max 2MB |
folder |
yes | categories or products |
filename |
no | Optional basename (phones → phones.jpg); omit for random name |
Response:
{
"path": "categories/phones.jpg",
"url": "http://localhost:8000/storage/categories/phones.jpg"
}Flow: upload → copy path → send as image on create/update category (JSON). Files are not deleted from disk when a category is updated or removed.
| Method | Endpoint | Description |
|---|---|---|
| GET | /categories |
Paginated list (per_page, sort, order) |
| POST | /categories |
Create category |
| GET | /categories/{slug} |
Show category |
| PUT/PATCH | /categories/{slug} |
Update category (partial with PATCH) |
| DELETE | /categories/{slug} |
Delete if it has no products (204); else 422 |
Query (GET /categories): per_page (1–50, default 10), sort (name | products_count), order (asc | desc).
Create/update body (JSON): name, slug (lowercase, numbers, hyphens), description (nullable), image (nullable string path from uploads).
| Method | Endpoint | Description |
|---|---|---|
| GET | /products |
Paginated list (includes inactive products) |
| POST | /products |
Create product |
| GET | /products/{slug} |
Show product |
| PUT/PATCH | /products/{slug} |
Update product (partial with PATCH) |
| DELETE | /products/{slug} |
Delete product (204) |
Query (GET /products): same as public shop — category, search, per_page, sort (name | price | created_at), order.
Create/update body (JSON): category_id, name, slug, description (nullable), price, stock, image (nullable path from uploads with folder=products), is_active (optional boolean).
Deleting a product removes it from carts; order items keep product_name and set product_id to null. Image files stay on disk.
Admins see all orders. Checkout stays on the customer API (POST /orders). Orders are not hard-deleted; change status instead.
| Method | Endpoint | Description |
|---|---|---|
| GET | /orders |
Paginated list of all orders |
| GET | /orders/{id} |
Order detail (any order) |
| PUT/PATCH | /orders/{id} |
Update status only |
Query (GET /orders): per_page (1–50, default 10), status (one of the values below), sort (total | created_at), order (asc | desc). Invalid status → 422.
Statuses: pending, processing, completed, cancelled, failed, refunded. New checkouts start as pending.
Moving an order from a held status (pending, processing, completed) to cancelled, failed, or refunded restores product stock. The same status again does not double-restore. Every status change sends an email to the customer (Mailpit locally) with the old and new status plus order items.
PATCH body:
{
"status": "processing"
}Customer → 403.
Shop-wide configuration. Admin reads and updates key/value pairs. Keys are defined in code (Setting::KEYS); admin can only change values, not add new keys.
| Method | Endpoint | Description |
|---|---|---|
| GET | /settings |
Return all settings as { key: value } map |
| PATCH | /settings |
Bulk update one or more settings |
PATCH body:
{
"settings": [
{ "key": "shop.name", "value": "My Shop" },
{ "key": "shop.theme_color", "value": "#ff0000" }
]
}Only keys from Setting::KEYS are accepted (others → 422). value can be null. Keys not sent are not changed.
Available keys: shop.name, shop.email, shop.phone, shop.address_line1, shop.address_line2, shop.city, shop.state, shop.zip, shop.country, shop.logo_url, shop.theme_color, shop.currency, shop.locale, shop.timezone, shop.orders_per_page, shop.products_per_page.
Customer → 403.
List and manage registered users. No create or delete — registration stays on the public API; hard delete stays on DELETE /profile.
| Method | Endpoint | Description |
|---|---|---|
| GET | /users |
Paginated list of all users |
| GET | /users/{id} |
User detail |
| PUT/PATCH | /users/{id} |
Update is_active only |
Query (GET /users): per_page (1–50, default 10), active (1 = active, 0 = inactive; omit for all), sort (role | created_at), order (asc | desc).
Response fields: id, name, email, role, is_active, email_verified_at, phone, shipping fields, created_at.
PATCH body:
{
"is_active": false
}Admin cannot change their own is_active → 403. Setting is_active to false revokes all Sanctum tokens for that user (existing Bearer tokens stop working). Customer → 403.
API routes return JSON. Missing resources respond with:
{
"message": "Not found."
}Status code: 404
cd backend
composer install
cp .env.example .env
php artisan key:generate
# configure MySQL in .env
php artisan migrate
php artisan storage:link
php artisan db:seed --class=CategorySeeder
php artisan db:seed --class=ProductSeeder
php artisan db:seed --class=CartSeeder
php artisan servenpm installUses Husky + Commitlint for conventional commits.
cd frontend
npm install
npm run devThings we skipped on purpose (MVP). Do these before production / when polishing:
- Optional: revoke other tokens after change-password
- Refresh-token style flow (only if needed; Sanctum is usually enough)
- Payment (Stripe / etc.)
- PHPStan / Larastan (unused imports, static analysis)
- API tests (Feature tests for auth, cart, orders)
- React storefront (Vite)
Use Conventional Commits:
feat: add cart endpoints
fix: correct product sort whitelist
docs: update readme file
chore: initial setup
README-only changes: docs: update readme file