A decoupled starter kit: a containerized API-only Symfony 8.1 backend (PHP 8.5 FPM, API Platform 4, JWT auth) and a React 19 SPA (Vite, Tailwind CSS 4, lucide icons) — login, registration, user profile and admin user-management screens included.
- Overview
- Key features
- Tech stack
- Architecture
- Prerequisites
- Quick start
- Services & URLs
- API reference
- Frontend
- Database & migrations
- Commands (Makefile)
- Tests
- Code quality
- Docker
- License
This repository is a starter kit for building a modern web application with a separated API and frontend:
- a backend in Symfony 8.1 running in API-only mode with API Platform 4:
one
Userresource (admin CRUD), a public registration endpoint, a JWT login, and a/api/meprofile endpoint; - a frontend in React 19 + Vite + Tailwind CSS 4 + lucide-react with four screens: login, registration, user profile (read/update own data) and admin user management (search, pagination, enable/disable, promote/demote, delete);
- the same production origin for the SPA and the API: Nginx serves the built React
app and proxies
/apito PHP-FPM — no CORS configuration needed; in development, the Vite dev server proxies/apito the Symfony container.
The backend keeps the opinionated base classes (
AbstractEntity,AbstractRepository, traits, fixtures, helpers) and the strict quality gates (PHPStan level 6, PHPCS PSR-12, PHPUnit 12). Build your own business entities and screens on top.
- API-first backend: API Platform 4 resources,
json/JSON-LD formats, built-in pagination (totalItems,member,view.first/last/next) and filtering. - JWT authentication (LexikJWTAuthenticationBundle):
POST /api/loginreturns a token; theapifirewall is stateless; a customUserCheckerrejects disabled accounts at authentication time. - Public registration: validated DTO (
RegisterInput) with unique-email check, password hashing and forcedROLE_USER. - Profile endpoint:
GET /api/me/PATCH /api/me(update email, names and optionally the password). - Admin user management:
GET/PATCH/DELETE /api/usersprotected byROLE_ADMINwith email/name search filters and a booleanisActivefilter. - React 19 SPA: TypeScript, React Router 7, TanStack Query 5, axios with a Bearer
interceptor and automatic 401 →
/loginredirect; Tailwind CSS 4 design system with lucide icons; guards (RequireAuth,RequireRole ROLE_ADMIN). - Same-origin in production: Nginx serves the SPA and proxies the API, so there
is no CORS and no exposed credentials beyond the JWT in
localStorage. - Full containerization: PHP 8.5 FPM, Nginx, PostgreSQL 18, Mailpit and an
optional Vite container (
node:24-alpine). - Strict quality gates: PHPStan level 6, PHP_CodeSniffer (PSR-12), PHPUnit 12,
ESLint and
tscfor the frontend.
| Area | Technology | Version |
|---|---|---|
| Language | PHP (FPM) | 8.5 |
| Framework | Symfony | 8.1 |
| API | API Platform | 4.x |
| Auth | Lexik JWT (stateless) | 3.x |
| Database | PostgreSQL | 18 (postgres:18.2-alpine) |
| ORM | Doctrine ORM / DBAL | 3.6 / 4.4 |
| Web server | Nginx | 1.29 (1.29.5-alpine) |
| Mail (dev) | Mailpit | axllent/mailpit |
| Frontend framework | React (+ TypeScript) | 19.x |
| Frontend tooling | Vite | 8.x |
| Styling | Tailwind CSS | 4.x (@tailwindcss/vite) |
| Icons | lucide-react | latest |
| Data fetching | TanStack Query / axios | 5.x / 1.x |
| Routing | React Router | 7.x |
| Quality (back) | PHPStan / PHP_CodeSniffer / PHPUnit | 2.x / 3.7 / 12 |
| Quality (front) | ESLint / TypeScript | 9.x / 5.x |
┌──────────────────────── browser ────────────────────────┐
│ │
DEV: Vite dev server (:5173, HMR) PROD: Nginx (:8081)
proxy /api ──────────────┐ │
┌───────────────────▼──────┐ ├─ / → frontend/dist (SPA)
│ Symfony 8.1 (API only) │ ── /api ─────────────┘
│ API Platform 4 │ /api/login, /api/register
│ JWT (Lexik) │ /api/me (profile)
│ Doctrine ORM 3 │ /api/users (admin CRUD)
│ PostgreSQL 18 · Mailpit │
└──────────────────────────┘
- Monorepo: the Symfony backend lives at the repository root; the React app
lives in
frontend/. - Both environments are single-origin, so no CORS is required anywhere.
- The SPA only renders screens; every authorization is enforced server-side
(firewall + API Platform
security+UserChecker).
- Docker ≥ 24 and Docker Compose (v2, included in the Docker CLI).
- Make to run the
Makefilerecipes. - Node.js ≥ 22 and npm for the frontend recipes (or use the
frontendDocker service). - Git to clone the repository.
- No local PHP/Composer/PostgreSQL installation is required: everything runs in the containers.
ℹ️ On Linux, make sure your user belongs to the
dockergroup (otherwise prefix the commands withsudo).
git clone <this-repository> react-symfony-starter
cd react-symfony-starter
# 1. Build the images, install backend + frontend dependencies, start everything
# and generate the JWT keypair (skipped if already present)
make install
# 2. Initialize the database
make migrate
make fixtures # admin@example.com / password, user1..3@example.com / password, inactive@example.com / password
# 3. Open the SPA (Vite dev server, proxies /api to the backend)
# → http://localhost:5173
# API documentation (Swagger UI) is served by the backend
# → http://localhost:8081/api/docsSign in with admin@example.com / password (admin) or user1@example.com /
password (regular user).
The frontend runs through Vite on the host (
make frontend-dev). For a fully containerized dev environment, start the dedicated service instead:docker compose up -d frontend→ http://localhost:5173.
| Service | URL / host | Notes |
|---|---|---|
| Frontend (dev, Vite + HMR) | http://localhost:5173 |
React 19 SPA, proxies /api. |
| Frontend (prod, built) | http://localhost:8081 |
Served by Nginx after make frontend-build. |
| Backend API | http://localhost:8081/api |
API Platform + custom endpoints. |
| Swagger UI | http://localhost:8081/api/docs |
Interactive API documentation. |
| Mailpit (web interface) | http://localhost:1181 |
Dev email viewer. |
| PostgreSQL (host) | localhost:5532 |
Mapped port from the database container. |
| Mailpit SMTP (host) | localhost:1126 |
Mapped SMTP port (internal 1025). |
| Method | Path | Description |
|---|---|---|
POST |
/api/login |
JSON {email, password} → {token} (JWT, 1h TTL). |
POST |
/api/register |
{email, plainPassword, firstName?, lastName?} → 201 + user. |
| Method | Path | Access | Description |
|---|---|---|---|
GET |
/api/me |
any authenticated user | Current profile (me:read). |
PATCH |
/api/me |
any authenticated user | Update email/names, optional plainPassword. |
| Method | Path | Access | Description |
|---|---|---|---|
GET |
/api/users |
ROLE_ADMIN |
Paginated list. Filters: email (partial), isActive (true/false). Pagination: page, itemsPerPage (max 100). |
GET |
/api/users/{id} |
ROLE_ADMIN |
Single user. |
PATCH |
/api/users/{id} |
ROLE_ADMIN |
Update email, firstName, lastName, roles, isActive. |
DELETE |
/api/users/{id} |
ROLE_ADMIN |
Delete the account (204). |
Response shape of the collection (JSON-LD):
{
"@context": "/api/contexts/User",
"@id": "/api/users",
"@type": "Collection",
"totalItems": 42,
"member": [ { "@id": "/api/users/1", "email": "…", "roles": ["ROLE_USER"], "isActive": true } ],
"view": { "@id": "/api/users?itemsPerPage=10&page=1", "first": "…", "last": "…", "next": "…" }
}Error handling: validation failures return 422 with
{"violations": [{"propertyPath": "email", "message": "…"}]}; bad credentials
return 401; missing admin rights return 403. The password hash is never
serialized.
Auth flow for the SPA: POST /api/login → store the JWT in localStorage
(starter_token) → the axios instance attaches Authorization: Bearer <token> →
any 401 clears the token and redirects to /login.
All per-environment configuration is centralized in .env (committed defaults,
overridable via .env.local, not versioned). The test environment uses .env.test.
| Variable | Default | Description |
|---|---|---|
APP_ENV |
dev |
Symfony environment (dev / prod / test). |
APP_SECRET |
6bd8b04b… |
Application secret key. |
APP_PORT |
8081 |
Host port exposed by Nginx. |
FRONTEND_PORT |
5173 |
Host port exposed by the Vite dev server. |
DATABASE_* |
root/password/symfony |
PostgreSQL credentials and database name. |
DATABASE_URL |
postgresql://… |
Doctrine DSN built from the variables above. |
JWT_SECRET_KEY / JWT_PUBLIC_KEY |
config/jwt/*.pem |
Lexik JWT keypair paths (generated by make jwt). |
JWT_PASSPHRASE |
random | Lexik JWT passphrase. |
MAILER_DSN |
smtp://mailer:1025 |
Mailer transport (Mailpit). |
MESSENGER_TRANSPORT_DSN |
doctrine://default?auto_setup=0 |
Messenger async transport (Doctrine DBAL). |
.
├── bin/ # Executables (console, composer, phpunit)
├── config/ # Symfony configuration
│ ├── packages/
│ │ ├── api_platform.yaml # API Platform 4 (pagination, cache headers)
│ │ └── security.yaml # Stateless `api` firewall (jwt + json_login)
│ └── routes.yaml # /api/login route + attribute routes
├── docker/
│ ├── nginx/default.conf # SPA static + /api → PHP-FPM
│ └── php/ # PHP 8.5 image, supervisord, entrypoint
├── frontend/ # React 19 SPA (Vite + Tailwind 4 + lucide)
│ ├── src/
│ │ ├── api/ # axios client, typed auth/users services
│ │ ├── auth/ # AuthContext, useAuth, RequireAuth/RequireRole
│ │ ├── components/ # ui.tsx design-system primitives
│ │ ├── layouts/ # AppLayout (header, role-aware nav)
│ │ ├── pages/ # Login, Register, Profile, admin/Users
│ │ └── lib/ # utils (cn, parseJwt, formatDate…)
│ ├── vite.config.ts # React + Tailwind plugins, /api proxy
│ └── package.json
├── migrations/ # Doctrine migrations
├── public/ # Symfony front controller (API only)
├── src/
│ ├── Controller/ # RegistrationController, MeController
│ ├── Entity/ # AbstractEntity, User (API Platform resource)
│ ├── Repository/ # AbstractRepository, UserRepository
│ ├── Dto/ # RegisterInput, MePayload (validated inputs)
│ ├── Security/ # UserChecker (rejects disabled accounts)
│ ├── OpenApi/ # JWT security scheme for Swagger UI
│ ├── DataFixtures/ # Admin + users fixtures
│ └── Kernel.php
├── tests/
│ ├── AbstractApiTestCase.php # ApiTestCase + Foundry (ResetDatabase)
│ └── Api/ # Login, Registration, Me, AdminUsers tests
├── docker-compose.yaml # database, php, nginx, frontend (dev), mailer
├── Makefile
├── composer.json
└── phpstan.dist.neon / phpcs.xml.dist / phpunit.xml.dist
The SPA lives in frontend/ and is kept intentionally small:
| Area | Stack |
|---|---|
| Build | Vite 8 + TypeScript strict |
| UI | Tailwind CSS 4 (@import "tailwindcss" + @theme), lucide-react icons |
| Routing | React Router 7 (/login, /register, /profile, /admin/users) |
| Server state | TanStack Query 5 (useQuery/useMutation, cache invalidation) |
| HTTP | axios instance (/api base URL, Bearer interceptor, 401 → /login) |
| Auth | AuthProvider (JWT in localStorage), useAuth, RequireAuth / RequireRole |
cd frontend
npm install
npm run dev # http://localhost:5173 (proxies /api → http://localhost:8081)
npm run build # tsc --noEmit && vite build → dist/ (served by Nginx in prod)
npm run lint # ESLint- Login (
/login) — email + password, redirect by role (admin →/admin/users, user →/profile), sign-up link. - Registration (
/register) — email, optional first/last name, password + confirmation; success redirects to login with a banner. - Profile (
/profile,RequireAuth) —GET /api/medata, edit form (PATCH /api/me), optional password change. - Users (
/admin/users,RequireAuth+ROLE_ADMIN) — paginated table with email search and status filter, enable/disable, promote/demote admin, delete with confirmation.
The whole UI (user and admin) shares one strict dark design system, defined as
Tailwind CSS 4 tokens in src/index.css (@theme):
| Token | Value |
|---|---|
| App background / stacked surfaces | #0e1117 · #161a22 → #1c212c → #232a38 |
| Borders | #262d3b (subtle) · #323c4f (strong) |
| Primary / hover / accent | #6c8cff · #5276f2 · #a78bfa |
| Semantics | success #34d399 · warning #fbbf24 · danger #f87171 |
| Text | #e2e8f0 (ink) · #8b93a5 (muted) · #5c6474 (faint) |
| Radii | 14px cards · 10px components · 0.6rem fields |
| Shadows | 0 8px 24px rgba(0,0,0,.3) cards · soft 0 4px 12px |
| Layout | sidebar 264px (icon bar < 992px) · content max 1320px · glass topbar (backdrop-blur) |
Hover/active states use soft translucencies (bg-primary/12, bg-white/5 …);
cards have a slightly contrasted header, tables use uppercase tracking-widest
headers, badges are pills and row actions are square icon buttons.
The connection is driven by DATABASE_URL (Doctrine DBAL 4). ORM mapping uses PHP
attributes (type: attribute) under src/Entity.
make connect # PHP shell
php bin/console doctrine:migrations:migrate # apply
php bin/console make:entity # modify an entity, then:
php bin/console make:migration # generate the migration
php bin/console doctrine:fixtures:load # load fixturesShortcuts:
make migrateandmake fixturesrun the same commands with--no-interaction. In thetestenvironment, Doctrine automatically suffixes the database name (_test…) to isolate data.
| Command | Description |
|---|---|
make install |
Build images + composer install + npm install + start + generate JWT keys. |
make start / make stop / make restart |
Start / stop / restart the containers. |
make connect |
Shell in the PHP container. |
make clear |
cache:clear in the PHP container. |
make jwt |
Generate the Lexik JWT keypair (skipped if it exists). |
make frontend-install |
npm install in frontend/. |
make frontend-dev |
Start the Vite dev server (HMR) on :5173. |
make frontend-build |
Type-check and build the SPA into frontend/dist. |
make frontend-lint |
ESLint on the SPA. |
make composer-install / make composer-update |
Composer in the PHP container. |
make migrate / make fixtures |
Migrations / fixtures (--no-interaction). |
make test |
PHPUnit suite in the container. |
make phpstan |
PHPStan static analysis (level 6). |
make cs / make csfix |
PHP_CodeSniffer (PSR-12) / auto-fix. |
make logs |
Follow container logs. |
make destroy |
Remove containers and volumes (docker compose down -v). |
PHPUnit 12 is configured via phpunit.xml.dist. Tests use
ApiTestCase (API Platform test
client) plus Zenstruck Foundry (ResetDatabase + Factories).
make testCoverage is declared on src/ (<source>). The suite covers the whole API:
registration (201, 422 duplicates/invalid payloads, forced role), login (token,
401 wrong credentials, 401 disabled account), profile (/api/me GET/PATCH,
password change, email conflict) and admin user management (401/403 guards,
pagination, filters, enable/disable, role change, delete).
| Tool | Command | Configuration |
|---|---|---|
| Static analysis (PHP) | vendor/bin/phpstan analyse |
phpstan.dist.neon — level 6. |
| Code style (PHP) | vendor/bin/phpcs |
phpcs.xml.dist — PSR-12. |
| Unit/API tests | php bin/phpunit |
phpunit.xml.dist — PHPUnit 12. |
| Lint (frontend) | npm run lint (in frontend/) |
ESLint (typescript-eslint, react-hooks). |
| Types/build (frontend) | npm run build (in frontend/) |
tsc --noEmit + vite build. |
All gates must be green before committing.
Orchestration is described in docker-compose.yaml (5 services):
| Service | Image / build | Role |
|---|---|---|
database |
postgres:18.2-alpine |
PostgreSQL 18, pg_isready healthcheck, persistent volume. |
php |
build docker/php |
PHP 8.5 FPM + Composer + Xdebug + extensions (pgsql, intl, apcu, sodium…). Supervisord manages the Messenger workers. |
nginx |
nginx:1.29.5-alpine |
Serves the built SPA and proxies /api → PHP-FPM. |
frontend |
node:24-alpine (dev) |
Vite dev server with HMR (optional; the host npm run dev works too). |
mailer |
axllent/mailpit |
SMTP sink + web interface. |
Notable points:
- The PHP image is built from
php:8.5-fpmwithinstall-php-extensionsfor reproducible builds. - The
dockeruser (NOPASSWD sudo) avoids permission issues with mounted volumes. phpdepends ondatabase(service_healthycondition);nginxandfrontenddepend onphp.- The frontend container keeps
node_modulesin a named volume so the dev-server install matches the container's libc (musl) — the host and container installs never clash.
To rebuild from scratch:
docker compose down -v # also removes volumes (DATABASE + frontend node_modules LOST)
make installDistributed under the MIT license — see the LICENSE file. Copyright © 2023 Louise SOULIER.