Système de gestion de traductions pensé en CMS headless : un back-office pour ceux qui traduisent, une API pour ce qui consomme. Architecture - Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en même origine — ce qui rend viable le cookie de session plutôt qu'un jeton en localStorage. - Deux APIs séparées : Management (session ou clé) et Delivery (stateless, clé seule). Les fusionner ferait porter à chaque lecture de bundle le coût de la session. - Stockage canonique en ICU MessageFormat, sérialisation par plateforme. Le format d'une plateforme ne contamine pas la base. - Publication par releases immuables ; le déploiement est un déplacement de pointeur, donc le rollback aussi. - Isolation multi-organisation par filtre Doctrine, avec un test d'architecture qui casse la CI si une entité échappe à l'invariant. Éditeur, deux vues - Par langue : source et cible, jamais douze colonnes. Grille virtualisée, saisie sans bouton « Enregistrer », panneau de contexte permanent. - Par clé : une clé, toutes ses langues empilées et repliées. Répond à « ce libellé est-il prêt partout ? ». - Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner. - Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ par catégorie CLDR de la langue cible. Administration - Deux niveaux : projet (membres, clés API, plateformes) et organisation (annuaire des comptes, création de projets). - Invitations par e-mail, jeton 256 bits stocké haché. - Désactiver un compte coupe les sessions en cours, pas seulement les connexions suivantes. - Les plateformes s'archivent ; ni elles ni les environnements ne se suppriment — la trace explique pourquoi telle clé existe. CLI tqs - PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend ni de Composer ni de la disponibilité de TQ-Slator. - init / push / pull / status ; le sync est non destructif par défaut et son prune est scopé plateforme. 126 tests, PHPStan niveau 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
424 lines
20 KiB
Markdown
424 lines
20 KiB
Markdown
# TQ-Slator
|
|
|
|
Système de gestion de traductions (TMS) headless — API-First, spécialisé i18n.
|
|
|
|
- **Management API** : Symfony 7.4 LTS + API Platform 4, OpenAPI généré depuis les attributs PHP.
|
|
- **Delivery API** : contrôleur dédié, bundles figés et immuables, ETag.
|
|
- **Base** : MariaDB 11.4.
|
|
- **Runtime** : FrankenPHP (worker mode en production).
|
|
|
|
L'architecture, les décisions et leurs justifications : [`docs/01-architecture-proposal.md`](docs/01-architecture-proposal.md).
|
|
|
|
---
|
|
|
|
## Démarrage
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
docker compose exec php bin/console doctrine:fixtures:load --no-interaction
|
|
```
|
|
|
|
| Service | URL |
|
|
|---|---|
|
|
| API | http://localhost:8080 |
|
|
| Documentation OpenAPI | http://localhost:8080/api/docs |
|
|
| Sonde de santé | http://localhost:8080/api/v1/health |
|
|
| Mailpit (invitations) | http://localhost:8025 |
|
|
|
|
Les migrations s'appliquent automatiquement au démarrage du conteneur `php`
|
|
(voir `docker/entrypoint.dev.sh`). Seul ce service migre ; le `worker` attend
|
|
que le schéma soit à jour avant de consommer.
|
|
|
|
## Comptes de démonstration
|
|
|
|
Mot de passe commun : `tqslator`.
|
|
|
|
| Compte | Rôle projet | Périmètre |
|
|
|---|---|---|
|
|
| `admin@tranquilys.test` | owner + super-admin | tout |
|
|
| `dev@tranquilys.test` | developer | clés, API, releases — **pas** les traductions |
|
|
| `maria@agence-lingua.test` | translator (externe) | es-ES, pt-BR **uniquement** |
|
|
| `jonas@agence-lingua.test` | translator (externe) | de-DE, nl-NL **uniquement** |
|
|
| `claire@tranquilys.test` | reviewer | es-ES, de-DE |
|
|
| `obs@tranquilys.test` | viewer | lecture seule |
|
|
|
|
Les deux comptes `agence-lingua` illustrent le RBAC à deux axes : María ne peut
|
|
pas écrire une ligne d'allemand, Jonas pas une ligne d'espagnol.
|
|
|
|
## Clés API de démonstration
|
|
|
|
```
|
|
tqs_test_development000000000000000000000 # translations:read, keys:read, keys:write, releases:read
|
|
tqs_test_staging0000000000000000000000000 # idem
|
|
tqs_live_production0000000000000000000000 # translations:read, releases:read — lecture seule
|
|
```
|
|
|
|
Déterministes en fixtures uniquement. La génération réelle passe par
|
|
`random_bytes` et le secret n'est affiché qu'une fois.
|
|
|
|
Vérifier une clé :
|
|
|
|
```bash
|
|
curl http://localhost:8080/delivery/v1/whoami \
|
|
-H 'Authorization: Bearer tqs_live_production0000000000000000000000'
|
|
```
|
|
|
|
## Jeu de données
|
|
|
|
Volontairement imparfait, pour que l'ergonomie soit jugeable dès le premier
|
|
écran : clés manquantes, sources modifiées après traduction (statut
|
|
`needs_review`), pluriels ICU, arabe en RTL avec six formes plurielles,
|
|
placeholders, et une clé rattachée à aucune plateforme (bac « Non assignées »).
|
|
|
|
## Back-office
|
|
|
|
Interface React servie par FrankenPHP en **même origine** que l'API — c'est ce
|
|
qui rend viable le cookie de session plutôt qu'un jeton en localStorage.
|
|
|
|
```bash
|
|
npm ci && npm run build # construit vers public/
|
|
npm run dev # Vite sur :5173, relaie /api vers :8080
|
|
```
|
|
|
|
Puis http://localhost:8080/login
|
|
|
|
Sept écrans, et un principe pour chacun :
|
|
|
|
- **Projets** — pas un tableau de bord. Répond à « où reste-t-il du travail, et
|
|
pour quelle langue ? », puis s'efface. Les langues sur lesquelles vous êtes
|
|
habilité passent en premier ; les autres sont grisées et marquées
|
|
« consultation ».
|
|
- **Éditeur** — trois zones et **deux façons de regarder le même contenu**
|
|
(voir plus bas). Grille virtualisée, saisie en ligne sans bouton
|
|
« Enregistrer », panneau de contexte permanent. Tous les filtres vivent dans
|
|
l'URL, y compris le choix de vue, donc un lien Slack amène la traductrice
|
|
exactement sur le bon travail.
|
|
- **Mode Focus** — une clé plein écran, `⌘↵` pour enregistrer et passer à la
|
|
suivante. Un traducteur ne vient pas explorer, il vient vider une file. Il
|
|
existe en deux variantes, une par vue : *une langue à la fois*, ou *toutes
|
|
celles qu'on a le droit d'écrire*.
|
|
- **Membres** *(administrateurs)* — invitation en trois champs (adresse, rôle,
|
|
langues), membres et invitations en attente dans la même liste. « Qui a
|
|
accès ? » doit se répondre d'un coup d'œil, accès accordés comme accès en
|
|
cours d'octroi.
|
|
- **Plateformes** *(administrateurs)* — les surfaces du projet et ses cibles de
|
|
déploiement, réunies parce qu'on s'y rend pour la même raison : rendre le
|
|
projet livrable. Une plateforme dit quelles clés entrent dans un fichier et
|
|
dans quelle syntaxe ; un environnement dit quelle version est servie.
|
|
- **Intégration** *(administrateurs)* — création et révocation des clés API.
|
|
Le secret n'apparaît **qu'une fois**. Les permissions d'écriture sont en
|
|
orange, les lectures en bleu : une clé de production n'a besoin que de
|
|
lecture, et la couleur le dit avant la documentation.
|
|
|
|
- **Administration** *(super-administrateurs, `/admin`)* — hors projet.
|
|
Deux onglets : tous les comptes de l'organisation avec leurs appartenances,
|
|
et tous les projets avec leur inventaire. C'est le seul endroit d'où l'on
|
|
crée un projet, désactive un compte ou nomme un super-administrateur.
|
|
|
|
Les entrées réservées aux administrateurs ne sont pas grisées, elles sont
|
|
**absentes** du menu. Un menu plein d'options inaccessibles apprend à
|
|
l'utilisateur que l'interface ne le concerne pas.
|
|
|
|
### Deux niveaux d'administration, pas un
|
|
|
|
| | Question posée | Qui répond | Où |
|
|
|---|---|---|---|
|
|
| **Projet** | Qui a accès à *ce* projet, et sur quelles langues ? | `owner` ou `admin` du projet | Onglets Membres et Intégration |
|
|
| **Organisation** | Qui existe dans l'outil, et où va-t-il ? | super-administrateur | `/admin` |
|
|
|
|
La seconde question traverse les projets, donc traverse aussi les
|
|
administrateurs de projet. Un administrateur du projet A n'a pas à savoir qui
|
|
travaille sur le projet B.
|
|
|
|
**Désactiver un compte coupe les sessions en cours**, pas seulement les
|
|
connexions suivantes : l'utilisateur est rechargé depuis la base à chaque
|
|
requête, et un compte inactif n'est plus trouvé. C'est ce qu'on attend d'un
|
|
bouton pressé au moment où un accès doit cesser.
|
|
|
|
Deux garde-fous : on ne modifie ni ne désactive son propre compte depuis cet
|
|
écran, et il doit rester au moins un super-administrateur actif.
|
|
|
|
### Archiver une plateforme, ne jamais la supprimer
|
|
|
|
Une plateforme **s'archive**. La supprimer effacerait son rattachement aux clés
|
|
via `translation_key_platform`, donc la seule trace expliquant pourquoi telle clé
|
|
existe.
|
|
|
|
Une fois archivée, elle sort des releases suivantes, refuse les `sync` (409, avec
|
|
un message qui dit que la décision est humaine et se défait depuis le
|
|
back-office), et disparaît des filtres de l'éditeur comme de `tqs init`. Elle
|
|
reste visible dans l'écran, avec sa date de retrait et son nombre de clés — le
|
|
chiffre qui rend la décision prenable. Les releases **déjà publiées** la
|
|
conservent : un instantané immuable ne se réécrit pas rétroactivement, sinon les
|
|
ETags déjà servis mentiraient.
|
|
|
|
La dernière plateforme active ne peut pas être archivée : le bouton est désactivé
|
|
avec l'explication, et le serveur refuse aussi.
|
|
|
|
Un environnement, lui, ne se supprime ni ne s'archive : les applications qui
|
|
l'interrogent cesseraient d'être servies, sans qu'aucun signal ne parte au moment
|
|
du geste. Le slug d'une plateforme comme d'un environnement est figé après
|
|
création — il vit dans les URL de livraison et dans les `tqs.config.json` des
|
|
dépôts clients.
|
|
|
|
Seuls les formats disposant réellement d'un sérialiseur (`icu`, `i18next`) sont
|
|
proposés à la création. Offrir `symfony` créerait une plateforme incapable de
|
|
recevoir un `sync` comme de produire un bundle : un choix sans issue, découvert
|
|
bien plus tard.
|
|
|
|
**Le traducteur ne voit jamais de syntaxe ICU.** Les variables s'affichent en
|
|
pastilles, les pluriels se saisissent dans un champ par catégorie CLDR de la
|
|
langue cible — trois en espagnol, six en arabe — et la recomposition en ICU
|
|
canonique se fait à l'enregistrement.
|
|
|
|
### Deux vues, deux questions
|
|
|
|
Une bascule en haut de l'éditeur, et le choix vit dans l'URL (`?view=`).
|
|
|
|
| | Question posée | Pour qui |
|
|
|---|---|---|
|
|
| **Par langue** *(défaut)* | Où en est mon travail en espagnol ? | La traductrice qui vide sa file |
|
|
| **Par clé** | Ce libellé est-il prêt partout ? | Avant une mise en production ; après avoir ajouté une clé |
|
|
|
|
En vue par clé, chaque ligne porte la source puis **une ligne par langue**,
|
|
repliée : code de langue, pastille de statut, valeur tronquée. On ouvre la seule
|
|
langue sur laquelle on veut agir. Sept champs de saisie ouverts d'emblée
|
|
produiraient un mur illisible — et le principe « deux langues à l'écran, jamais
|
|
douze » reste tenu, autrement : les langues sont **empilées et repliées**, pas
|
|
juxtaposées en colonnes.
|
|
|
|
Deux différences à connaître :
|
|
|
|
- **Le filtre de statut change de sens.** Par langue, « à traduire » désigne une
|
|
clé non traduite *dans cette langue*. Par clé, il désigne une clé non traduite
|
|
dans **au moins une** langue. Les compteurs de l'arbre des namespaces suivent
|
|
la vue, sinon ils contrediraient la liste qu'ils surplombent.
|
|
- **La recherche porte sur toutes les langues.** Chercher `Cancel` en vue par clé
|
|
trouve la clé même si le mot n'existe qu'en anglais.
|
|
|
|
Un traducteur voit **toutes** les langues en vue par clé — c'est même l'intérêt,
|
|
disposer des autres comme contexte — mais les langues hors de son habilitation
|
|
sont marquées « consultation » et n'offrent aucun champ de saisie. Le serveur
|
|
refuse également, langue par langue.
|
|
|
|
### Le mode Focus, en deux variantes
|
|
|
|
| | La file contient | Sur l'écran |
|
|
|---|---|---|
|
|
| **Par langue** | Les clés à traiter dans la langue affichée | Un champ |
|
|
| **Par clé** | Les clés à traiter dans **au moins une des langues qu'on peut écrire** | Un champ par langue restante |
|
|
|
|
Le gain de la seconde est précis, et c'est le seul qui la justifie : **la source
|
|
et son contexte se lisent une fois pour plusieurs langues**. María, habilitée en
|
|
espagnol et en portugais, rencontrait la même clé dans deux files séparées et
|
|
relisait deux fois « Annuler la séance » avant d'écrire deux phrases voisines.
|
|
|
|
`⌘↵` descend d'un champ, puis passe à la clé suivante — le geste reste celui du
|
|
mode Focus par langue, il traverse simplement plusieurs champs avant de changer
|
|
de clé. Les langues **déjà traduites** de la clé restent affichées en dessous,
|
|
en lecture : une traduction voisine tranche souvent mieux un registre que la
|
|
source elle-même.
|
|
|
|
La file ne contient que du faisable. Une clé qui ne manque qu'en allemand n'entre
|
|
pas dans la file d'une traductrice espagnole. Le sous-ensemble de langues est
|
|
calculé **côté serveur** à partir de l'identité courante (`?focus=1`), jamais
|
|
reçu du client : une file qu'on pourrait demander pour les langues d'un autre
|
|
serait une fuite d'information déguisée en confort.
|
|
|
|
### Inviter quelqu'un
|
|
|
|
```
|
|
Membres → Inviter → adresse, rôle, langues
|
|
```
|
|
|
|
L'e-mail part de façon asynchrone (Messenger). En développement il atterrit
|
|
dans **Mailpit : http://localhost:8025**. Le lien vaut 2 jours.
|
|
|
|
La page d'acceptation montre le projet, le rôle et les langues **avant** de
|
|
demander quoi que ce soit — on ne demande pas de créer un compte sans dire
|
|
pour quoi. Le mot de passe (12 caractères minimum) n'est défini que si le
|
|
compte n'existe pas encore : inviter une adresse déjà connue ne permet jamais
|
|
d'en réinitialiser le mot de passe. La connexion est automatique à
|
|
l'acceptation.
|
|
|
|
## Management API
|
|
|
|
Documentation OpenAPI générée depuis les attributs PHP : http://localhost:8080/api/docs
|
|
|
|
| | |
|
|
|---|---|
|
|
| `GET /api/v1/projects` | Projets visibles — filtrés au niveau SQL |
|
|
| `GET /api/v1/projects/{uuid}/grid` | **Grille de l'éditeur** : clé + source + cible en une requête |
|
|
| `GET /api/v1/projects/{uuid}/keys` | **Vue par clé** : clé + source + toutes les langues en une requête |
|
|
| `GET/PATCH /api/v1/keys/{uuid}/translations/{locale}` | Écriture validée par le moteur de format |
|
|
| `POST /api/v1/projects/{uuid}/platforms/{slug}/sync` | **Endpoint du CLI** : inventaire des clés, diff en retour |
|
|
| `GET /api/v1/locales` | Référentiel de langues (lecture seule) |
|
|
| `GET/POST /api/v1/projects/{uuid}/platforms` | Plateformes |
|
|
| `GET/POST/PATCH /api/v1/projects/{uuid}/platforms-admin` | Plateformes : création, réglages, archivage |
|
|
| `GET/POST/PATCH /api/v1/projects/{uuid}/environments` | Environnements : création et renommage |
|
|
| `GET/PATCH /api/v1/admin/users` | Annuaire des comptes — super-admin uniquement |
|
|
| `GET/POST /api/v1/admin/projects` | Inventaire et création de projets — super-admin uniquement |
|
|
|
|
Deux identités authentifient la Management API : **session cookie** pour le
|
|
back-office, **clé API** pour le CLI. Les Voters savent raisonner sur les deux —
|
|
une clé ne vaut que pour son projet, et ne peut jamais administrer ni créer
|
|
d'autres clés.
|
|
|
|
`/api/v1/admin/` est hors de portée d'une clé API quelles que soient ses
|
|
permissions, et la règle est posée deux fois : dans `access_control` et par un
|
|
`#[IsGranted]` sur chaque contrôleur. Une clé vit dans un dépôt et une chaîne
|
|
d'intégration continue ; elle n'a rien à faire près des comptes.
|
|
|
|
Trois garanties tenues par les tests :
|
|
|
|
- **Un non-membre reçoit 404, pas 403.** L'absence et l'interdiction sont
|
|
indiscernables : le nom d'un produit avant son annonce est une information.
|
|
- **Le `sync` est non destructif par défaut** et son `prune` est scopé
|
|
plateforme : retirer une clé du code web ne peut pas archiver une clé iOS.
|
|
- **Une traduction qui perd ou invente une variable est refusée** (422), de même
|
|
qu'un pluriel arabe incomplet. Un dépassement de longueur passe, en
|
|
avertissement.
|
|
|
|
## CLI `tqs`
|
|
|
|
Le binaire que les développeurs d'applications clientes installent. C'est lui
|
|
qui décide si l'outil est adopté ou contourné.
|
|
|
|
```bash
|
|
# Construire le binaire (un fichier, ~3 Mo, aucune dépendance)
|
|
php -d phar.readonly=0 bin/build-tqs-phar.php
|
|
cp build/tqs.phar /usr/local/bin/tqs
|
|
|
|
# Dans le dépôt de l'application cliente
|
|
export TQS_API_KEY=tqs_test_…
|
|
tqs init # déduit projet, environnement, plateformes et langues de la clé
|
|
tqs push --dry-run # ce qu'un envoi changerait
|
|
tqs push # applique
|
|
tqs pull # récupère les traductions publiées
|
|
tqs status # avancement par langue
|
|
```
|
|
|
|
En intégration continue :
|
|
|
|
```bash
|
|
tqs push --prune # code de sortie 2 si des clés sont refusées
|
|
tqs pull --check # échoue si des fichiers ne sont pas commités
|
|
tqs status --fail-under=80 # échoue si une langue passe sous 80 %
|
|
```
|
|
|
|
**La clé API n'est jamais dans `tqs.config.json`.** Ce fichier est commité — il
|
|
décrit le lien entre un dépôt et un projet, ce qui est une information d'équipe.
|
|
Le secret vient de `TQS_API_KEY`, et le CLI refuse explicitement un champ
|
|
`apiKey` dans la configuration : une clé commitée reste dans l'historique Git.
|
|
|
|
Le CLI ne connaît **rien** aux formats de messages : il aplatit du JSON et parle
|
|
HTTP. Tout le reste — parsing ICU, regroupement des pluriels i18next, validation
|
|
— vit côté serveur. Dupliquer le moteur dans le CLI garantirait qu'il diverge, et
|
|
un CLI qui valide différemment du serveur est pire que pas de validation.
|
|
|
|
## Releases et Delivery API
|
|
|
|
Publier fige le contenu en fichiers **immuables**, un par (plateforme, langue).
|
|
Déployer déplace un pointeur. Revenir en arrière déplace le même pointeur dans
|
|
l'autre sens — il n'y a pas d'endpoint de rollback, parce qu'il n'y a rien de
|
|
particulier à faire.
|
|
|
|
```bash
|
|
P=<uuid-projet>
|
|
E=<uuid-environnement>
|
|
|
|
# Publier, et déployer dans la foulée sur la recette
|
|
curl -X POST localhost:8080/api/v1/projects/$P/releases/publish \
|
|
-H 'Content-Type: application/ld+json' \
|
|
-d '{"notes":"Corrections espagnoles","deployTo":["staging"]}'
|
|
|
|
# Comparer deux versions
|
|
curl localhost:8080/api/v1/releases/<uuid-v1>/diff/<uuid-v2>
|
|
|
|
# Déployer — ou revenir en arrière, c'est le même appel
|
|
curl -X PUT localhost:8080/api/v1/environments/$E/release \
|
|
-H 'Content-Type: application/ld+json' -d '{"version":42}'
|
|
|
|
# Ce que lit l'application en production
|
|
curl localhost:8080/delivery/v1/tranquilys/production/web/es-ES.json \
|
|
-H 'Authorization: Bearer tqs_live_…'
|
|
```
|
|
|
|
Quatre propriétés que la publication continue ne peut pas offrir :
|
|
|
|
- **Un brouillon ne peut pas atteindre la production.** La Delivery API ne sait
|
|
lire que `release_bundle`, et un bundle ne contient que du publiable.
|
|
- **Rollback instantané.** Aucun fichier n'est régénéré.
|
|
- **Cache agressif sans invalidation.** Le contenu d'une release ne change
|
|
jamais ; seul le pointeur d'environnement bouge, et il n'est pas caché.
|
|
- **Diff entre versions**, calculé sur les fichiers réellement produits — donc
|
|
un changement de repli y apparaît, puisque l'utilisateur final le verra.
|
|
|
|
La publication est **tout ou rien** : si un seul bundle ne peut pas être
|
|
construit (`select` de genre vers i18next, conflit de structure imbriquée…),
|
|
aucun n'est enregistré et la réponse liste tout ce qu'il faut corriger.
|
|
|
|
## Moteur de format
|
|
|
|
Le stockage est **toujours** en ICU MessageFormat canonique ; les formats des
|
|
plateformes ne sont que des projections produites à la publication.
|
|
|
|
```
|
|
src/Translation/Format/
|
|
├── Ast/ nœuds : littéral, argument, plural, selectordinal, select, dièse
|
|
├── Parser/ IcuParser (récursif descendant), I18nextParser
|
|
├── Serializer/ IcuSerializer (canonique déterministe), I18nextSerializer
|
|
├── PlaceholderExtractor.php alimente translation_key.placeholders
|
|
├── MessageValidator.php erreurs bloquantes vs avertissements
|
|
└── MessageFormatRegistry.php aiguillage par MessageFormat
|
|
```
|
|
|
|
Trois invariants tenus par les tests :
|
|
|
|
- **`parse(serialize(ast)) == ast`** sur tout le corpus ICU.
|
|
- **La forme canonique est un point fixe** — condition de stabilité de
|
|
`source_checksum`, donc de l'absence de bascules `needs_review` fantômes.
|
|
- **Une construction non exprimable fait échouer la publication**, avec un
|
|
message qui dit quoi faire (`select` de genre, `offset:`, variable de pluriel
|
|
autre que `count`, sélecteur exact autre que `=0`).
|
|
|
|
Transformations lossy documentées et testées : un pluriel en milieu de phrase est
|
|
redistribué dans chaque clé i18next (rendu identique, structure différente), et
|
|
le type d'un argument ICU est perdu (le nom survit). Voir
|
|
`docs/01-architecture-proposal.md` §4.3.
|
|
|
|
## Développement
|
|
|
|
```bash
|
|
docker compose exec php composer install
|
|
docker compose exec php vendor/bin/phpstan analyse --memory-limit=1G # niveau 8, doit rester à zéro
|
|
docker compose exec php bin/phpunit
|
|
docker compose exec php bin/console doctrine:migrations:migrate
|
|
```
|
|
|
|
### Points d'attention
|
|
|
|
- **`doctrine:migrations:diff` proposera de supprimer `idx_tkp_platform_key`.**
|
|
Ne pas accepter : Doctrine ne sait pas exprimer un index sur une table de
|
|
jointement ManyToMany, cet index est déclaré à la main dans la migration
|
|
initiale et sert la requête de grille.
|
|
- **Le dépôt est configuré en `core.ignorecase false`.** Les clés de traduction
|
|
sont sensibles à la casse, et macOS ne l'est pas par défaut.
|
|
- **Composer résout contre PHP 8.4**, la version du conteneur, pas celle de la
|
|
machine de dev. Voir `config.platform` dans `composer.json`.
|
|
|
|
## Structure
|
|
|
|
```
|
|
src/
|
|
├── Controller/ Auth, santé — les ressources métier passent par API Platform
|
|
├── DataFixtures/ Jeu de démonstration
|
|
├── Doctrine/ Filtre d'isolation multi-organisation
|
|
├── Entity/ 21 entités
|
|
├── Enum/ Statuts, rôles, formats, scopes
|
|
├── EventListener/ Établissement du contexte d'organisation
|
|
├── Message/ Messages asynchrones (Messenger)
|
|
├── Repository/ Requêtes — dont la requête de grille de l'éditeur
|
|
├── Security/ Authenticator par clé API, Voters
|
|
└── Tenant/ Contexte d'organisation
|
|
```
|