Architecture¶
Principes directeurs¶
L'architecture de WonderWork repose sur quelques décisions fondamentales :
- API centralisée — toute la logique métier vit dans l'API Symfony. Les frontends Next.js sont des clients comme les autres — ils ne contiennent aucune logique métier.
- Async pour les opérations lourdes — le calcul des scores de matching, l'envoi d'emails et le parsing de CV passent par une queue Symfony Messenger. Les requêtes HTTP restent rapides.
- DTOs à la frontière API — les entités Doctrine ne sont jamais exposées directement. Des Output DTOs contrôlent exactement ce qui sort de l'API ; des Input DTOs valident ce qui entre.
- Types TypeScript générés — les types côté frontend sont générés automatiquement depuis le spec OpenAPI du backend. Ils ne sont jamais écrits à la main.
Stack technique¶
| Outil | Version | Rôle |
|---|---|---|
| PHP | 8.4 | Runtime |
| Symfony | 7.4 LTS | Framework — routing, DI, Messenger, Mailer, Console |
| API Platform | 4.3.x | Génération REST + OpenAPI depuis les DTOs |
| Doctrine ORM | 3.x | Mapping objet-relationnel, migrations |
| PostgreSQL | 17 | Base de données principale |
| Redis | 7.x | Cache applicatif, sessions, rate limiting |
| Symfony Messenger | bundled | Queue async (calcul matching, emails) |
| LexikJWT | latest | Authentification JWT — access token 15 min |
| gesdinet/jwt-refresh-token | latest | Refresh token rotatif — révocation en cas de vol |
| Symfony Mailer + Brevo | latest | Emails transactionnels via SMTP Brevo |
| PHPUnit | 13 | Tests unitaires (logique isolée) |
| Behat | 3.x | Tests fonctionnels BDD — scénarios Gherkin |
| Zenstruck Foundry | 2.6 | Factories et stories pour les seeds |
| ECS | 13.x | Code style PHP CS Fixer @Symfony |
| PHPStan | 2.1.x | Analyse statique niveau 8 |
Pourquoi API Platform ? API Platform génère automatiquement les routes REST, la pagination, les filtres, la documentation OpenAPI et le schéma JSON-LD à partir des classes PHP annotées. Cela évite d'écrire des controllers répétitifs pour chaque ressource. Les cas complexes sont gérés par des State Providers/Processors custom.
Pourquoi PostgreSQL ? Les index GIN trigram (pg_trgm) permettent l'autocomplete de villes sur ~140k entrées sans Elasticsearch. Les contraintes FK et d'unicité sont respectées au niveau DB. Le JSON natif est utilisé pour les champs flexibles (ex: preferred_sectors).
Pourquoi Redis ? Le cache applicatif Symfony est configuré sur Redis — les résultats de requêtes fréquentes (liste de compétences, villes populaires) sont mis en cache. Redis gère aussi le rate limiting des endpoints publics.
| Outil | Version | Rôle |
|---|---|---|
| Next.js | 15 | Framework App Router |
| React | 19 | UI |
| TypeScript | 5.x | Typage strict |
| Tailwind CSS | 4.x | CSS-first, utility classes |
| shadcn/ui | latest | Composants accessibles (Radix UI + Tailwind) |
| TanStack Query | 5.x | Appels API + cache client |
| Zustand | 5.x | État global UI (auth, etc.) |
| React Hook Form + Zod | 7.x / 3.x | Formulaires guidés + validation |
| Serwist | latest | PWA — service worker, manifest |
Pourquoi Next.js App Router ? Les layouts imbriqués, le routing basé sur le filesystem et les Server Components permettent de structurer l'app par domaine métier (onboarding, passport, jobs, applications). L'App Router gère nativement la mise en cache et le streaming.
Pourquoi une PWA ? L'app candidat est mobile-first — les candidats consultent leurs matches et postulent depuis leur téléphone. La PWA permet l'installation sur l'écran d'accueil et un comportement natif.
Pourquoi TanStack Query ? La gestion du cache client, des états de chargement, des erreurs et de l'invalidation est complexe à écrire manuellement. TanStack Query gère tout ça — les composants déclarent ce dont ils ont besoin, pas comment le fetcher.
| Outil | Version | Rôle |
|---|---|---|
| Next.js | 15 | Framework App Router |
| React | 19 | UI |
| TypeScript | 5.x | Typage strict |
| Tailwind CSS | 4.x | CSS-first |
| shadcn/ui | latest | Composants UI |
| TanStack Query | 5.x | Appels API + cache |
| TanStack Table | 8.x | Tables de données complexes (tri, filtres, pagination) |
| dnd-kit | latest | Drag & drop — board Kanban du pipeline ATS |
| Recharts | latest | Graphiques du dashboard (candidatures, conversions) |
| Zustand | 5.x | État global (auth, company) |
Pourquoi TanStack Table ? Le pipeline ATS affiche des tables de candidats avec tri multi-colonnes, filtres combinés et pagination. TanStack Table est headless — il gère la logique sans imposer de styles, ce qui s'intègre parfaitement avec Tailwind.
Pourquoi dnd-kit ? Le pipeline Kanban (board de candidatures par étape) nécessite du drag-and-drop accessible. dnd-kit est la bibliothèque de référence pour React avec support clavier et screen readers.
Flux de données¶
flowchart TD
subgraph Frontend["Frontend (candidate / recruiter)"]
Browser["Browser\n(fetch /v1/*)"]
NextServer["Next.js Server\n(App Router)"]
Browser -->|"relative /v1/..."| NextServer
end
subgraph API["API (Symfony)"]
Controller["API Platform\n(State Providers/Processors)"]
Service["Services\n(logique métier)"]
Messenger["Symfony Messenger\n(queue async)"]
ORM["Doctrine ORM"]
end
NextServer -->|"rewrite /v1/* → API_INTERNAL_URL"| Controller
Controller --> Service
Service --> ORM
Service --> Messenger
ORM --> DB[(PostgreSQL)]
Messenger -->|async| Worker["Worker\n(matching, emails)"]
Worker --> DB
Worker -->|SMTP| Brevo["Brevo"]
Pourquoi les requêtes passent-elles par Next.js ? Les apps Next.js ne sont pas des SPA classiques — elles incluent un serveur Node.js. Toutes les requêtes fetch /v1/* passent par ce serveur qui les redirige vers l'API Symfony via une réécriture interne (API_INTERNAL_URL). Cela permet :
- Les cookies
httpOnly(JWT) de traverser naturellement sans être accessibles en JavaScript côté browser - L'API Symfony de ne pas être exposée directement à internet (URL interne non routée)
- De centraliser les headers d'authentification
Authentification¶
sequenceDiagram
participant Browser
participant NextServer as Next.js Server
participant API as Symfony API
Browser->>NextServer: POST /v1/auth/login
NextServer->>API: POST /v1/auth/login
API-->>NextServer: Set-Cookie: jwt (httpOnly) + refresh_token (httpOnly)
NextServer-->>Browser: cookies transmis
Browser->>NextServer: GET /v1/me (cookie auto)
NextServer->>API: GET /v1/me (cookie forwarded)
API-->>NextServer: User payload
NextServer-->>Browser: données utilisateur
Note over Browser,API: JWT expire après 15 min
Browser->>NextServer: POST /v1/auth/refresh
NextServer->>API: POST /v1/auth/refresh (refresh token)
API-->>NextServer: nouveau JWT (rotation du refresh token)
JWT en cookie httpOnly — le token n'est jamais accessible en JavaScript (document.cookie). Il ne peut pas être volé via XSS.
Refresh token rotatif — à chaque renouvellement, l'ancien refresh token est invalidé et un nouveau est émis. Si un token déjà utilisé est soumis (signe de vol), tous les tokens de l'utilisateur sont révoqués immédiatement.
Durées différenciées — JWT 15 min (candidate + recruiter). Refresh token 30 jours pour le candidat (usage mobile, sessions longues) vs session pour le recruteur (usage desktop professionnel, sécurité accrue).
Conventions API¶
JSON-LD partout — toutes les requêtes et réponses utilisent application/ld+json. Aucun application/json plain. JSON-LD ajoute des métadonnées sémantiques (@context, @type, @id) qui permettent à API Platform de générer une documentation OpenAPI riche et un hypermedia navigable.
Préfixe : /v1/ — versionnage explicite de l'API.
| Pattern | Exemple | Quand l'utiliser |
|---|---|---|
| Collection | GET /v1/job-offers |
Ressource publique ou multi-tenant |
| Ressource de l'utilisateur connecté | GET /v1/me/matches |
Données propres à l'utilisateur authentifié |
| Accès tiers | GET /v1/candidates/{id}/matches |
Admin ou recruteur accédant à une ressource d'un autre utilisateur |
| Action non-CRUD | POST /v1/applications/{id}/withdraw |
Transitions d'état ou commandes métier |
Pas de verbes dans les URLs — le verbe HTTP est le verbe.
Conventions Symfony¶
Injection de dépendances : constructeur uniquement. Jamais de service locator ($container->get(...)). Toutes les dépendances sont déclarées explicitement — le code est testable et les dépendances circulaires sont détectées au démarrage.
Repositories : seul endroit où les requêtes Doctrine sont écrites. Les services orchestrent ; les repositories lisent et écrivent la base.
DTOs : jamais d'entités Doctrine exposées directement via l'API.
- Input DTOs (
src/DTO/Input/) — valident et typent les données entrantes. API Platform les valide avec les contraintes Symfony avant d'appeler le Processor. - Output DTOs (
src/DTO/Output/) — contrôlent exactement quels champs sont sérialisés dans la réponse. Un champ ajouté à l'entité n'apparaît pas automatiquement dans l'API.
State Providers/Processors (src/State/) : toute logique custom d'API Platform. Pas de controllers Symfony classiques — API Platform route tout vers ces classes.
Génération des types TypeScript¶
Les types TypeScript des frontends sont générés depuis le spec OpenAPI du backend — jamais écrits à la main pour les shapes API.
# Depuis candidate/ ou recruiter/
curl -s http://localhost:8000/v1/docs \
-H "Accept: application/vnd.openapi+json" > /tmp/openapi.json
npx openapi-typescript /tmp/openapi.json -o src/types/api.ts
src/types/api.ts est auto-généré — ne jamais éditer manuellement. Après tout changement de DTO backend, régénérer dans tous les frontends concernés.
Pourquoi cette contrainte ? Sans génération automatique, les types frontend et les réponses API dérivent progressivement. Un champ renommé côté backend provoque des erreurs silencieuses côté frontend — le TypeScript compile mais les données sont undefined. La génération depuis OpenAPI rend ces divergences visibles au moment du build.
Structure des dossiers¶
src/
├── ApiResource/ ← Définitions API Platform (attributs #[ApiResource])
├── Command/ ← Commandes console Symfony (seed, import, setup)
├── DTO/
│ ├── Input/ ← LoginInput, PassportInput, JobOfferInput…
│ └── Output/ ← UserOutput, MatchItemOutput, ApplicationOutput…
├── Entity/
│ ├── Application/ ← Application, ApplicationStageHistory
│ ├── Auth/ ← EmailVerificationToken
│ ├── Candidate/ ← Candidate, Passport, PassportSkill…
│ ├── Job/ ← JobOffer, JobOfferSkill, PipelineStage…
│ ├── Matching/ ← MatchScore
│ ├── Recruiter/ ← Recruiter, Company, CompanyCultureValue
│ ├── Reference/ ← City, Skill, Nationality, CultureValue
│ ├── Rome/ ← RomeJob, RomeJobAppellation, RomeJobSkill
│ ├── RefreshToken.php
│ └── User.php ← Base CTI (auth + identité)
├── Enum/ ← Sector, EducationLevel, ContractType, ApplicationStatus…
├── Message/Email/ ← Messages async pour les emails
├── MessageHandler/ ← Handlers Symfony Messenger
├── Repository/ ← Requêtes Doctrine par domaine métier
├── Service/
│ └── Matching/ ← MatchingBloc1-6, PreFilter, ScoreCalculator
├── State/ ← Providers + Processors API Platform
├── Story/ ← Foundry stories (seed dev/test)
└── Validator/ ← Contraintes de validation custom
src/
├── app/
│ ├── (auth)/ ← login, register (routes publiques)
│ └── (app)/ ← dashboard, passport, jobs, applications (protégé)
├── components/
│ ├── ui/ ← shadcn/ui (ne pas éditer manuellement)
│ ├── jobs/
│ ├── passport/
│ └── layout/
├── hooks/
├── stores/ ← Zustand (authStore, etc.)
├── lib/
│ ├── api/ ← TanStack Query hooks + fetch functions
│ └── schemas/ ← Zod schemas (validation formulaires uniquement)
└── types/ ← api.ts (auto-généré) + types locaux
src/
├── app/
│ ├── (auth)/ ← login
│ └── (app)/ ← dashboard, job-offers, pipeline, candidates, settings
├── components/
│ ├── ui/ ← shadcn/ui
│ ├── job-offers/
│ ├── pipeline/ ← Board Kanban, drawer candidature
│ ├── candidates/
│ └── layout/
├── hooks/
├── stores/ ← authStore, companyStore
├── lib/api/
└── types/