Le domaine se pose désormais dans l'interface, sur le service `php`. `SERVICE_FQDN_PHP_80` demandait à Coolify de GÉNÉRER un domaine en …sslip.io. Utile pour un modèle en un clic, mais en concurrence avec un domaine renseigné à la main : deux routes pour un même service, dont une seule correspond au DNS réel. Le proxy répond alors 403 sur le domaine qu'il ne connaît pas — un code qui n'apparaît nulle part dans l'application, laquelle sert le back-office sur `/` sans jamais rien refuser. `expose: ['80']` ajouté. L'image exposait déjà 80, mais aussi 443 et 2019 (l'endpoint d'administration de Caddy) : rien n'obligeait un proxy à deviner lequel des trois sert l'application. Vérifié depuis le réseau interne, avec l'en-tête Host public : `/`, `/login` et `/api/v1/health` répondent 200, et les variables d'environnement arrivent bien dans le conteneur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| assets | ||
| bin | ||
| config | ||
| docker | ||
| docs | ||
| frankenphp | ||
| migrations | ||
| public | ||
| src | ||
| templates | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env | ||
| .env.dev | ||
| .env.test | ||
| .gitignore | ||
| .php-cs-fixer.dist.php | ||
| compose.override.yaml | ||
| compose.prod.yaml | ||
| compose.yaml | ||
| composer.json | ||
| composer.lock | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| phpstan.neon | ||
| phpunit.dist.xml | ||
| README.md | ||
| symfony.lock | ||
| tsconfig.json | ||
| vite.config.ts | ||
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.
Démarrage
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é :
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.
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
Cancelen 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
syncest non destructif par défaut et sonpruneest 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é.
# 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 :
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.
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)) == astsur tout le corpus ICU.- La forme canonique est un point fixe — condition de stabilité de
source_checksum, donc de l'absence de basculesneeds_reviewfantômes. - Une construction non exprimable fait échouer la publication, avec un
message qui dit quoi faire (
selectde genre,offset:, variable de pluriel autre quecount, 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éploiement
La pile de développement (compose.yaml) ne part pas en production : elle
cible l'image app_dev, monte les sources en volume, publie des ports et
branche Mailpit. compose.override.yaml, que Docker fusionne automatiquement,
en rajouterait. La production a donc son propre fichier :
docker compose -f compose.prod.yaml up -d --build
Ce que l'image de production contient
Le back-office React est construit dans l'image, par un étage Node dédié.
C'est indispensable et non une commodité : public/build/ et
public/index.html sont dans .gitignore. Sans cet étage, l'application
démarre, l'API répond, et chaque URL de l'interface renvoie 503 « Le back-office
n'est pas construit » — une panne partielle, donc plus déroutante qu'une panne
franche. Une vérification au build échoue si le front manque.
npm run build lance tsc --noEmit avant Vite : une erreur de typage casse le
déploiement, pas la production.
Variables à renseigner
Quatre sont obligatoires. Absentes, le déploiement s'arrête au lieu de démarrer un service à moitié configuré.
| Variable | Rôle |
|---|---|
APP_SECRET |
Signature des cookies de session. openssl rand -hex 32 |
MARIADB_PASSWORD |
Mot de passe applicatif MariaDB |
MARIADB_ROOT_PASSWORD |
Mot de passe root MariaDB |
BACK_OFFICE_URL |
URL publique, https://…. Sert aux liens d'invitation |
MAILER_DSN |
Relais SMTP réel. Mailpit ne part pas en production |
Facultatives : MAILER_FROM (expéditeur, défaut no-reply@tranquilys.com),
DEFAULT_ORGANIZATION_SLUG, RELEASE_RETENTION_UNREFERENCED,
SYMFONY_TRUSTED_PROXIES, CORS_ALLOW_ORIGIN, INVITATION_TTL.
BACK_OFFICE_URL sur localhost est refusé au démarrage. Une valeur fausse
ne casse rien de visible : elle casse la réception des invitations, ailleurs,
plusieurs jours plus tard.
Premier démarrage
Les comptes s'obtiennent par invitation, et inviter demande d'être connecté. Une installation neuve a donc besoin d'un amorçage :
docker compose -f compose.prod.yaml exec php \
php bin/console app:create-super-admin vous@exemple.fr "Votre Nom"
Le mot de passe est demandé sans être affiché — il ne reste donc pas dans l'historique du shell. Douze caractères minimum, comme à l'acceptation d'une invitation : un premier compte plus faible que les suivants serait le maillon par lequel on entre.
La commande refuse une adresse déjà connue : elle deviendrait sinon un moyen de s'attribuer le compte d'un autre.
Le référentiel de langues est installé par une migration, pas par les fixtures — c'est une donnée de référence, identique pour tout le monde, et sans elle aucun projet ne peut être créé faute de langue source à choisir.
Déployer via Coolify
Le build pack doit être « Docker Compose ». C'est le réglage qui décide de tout le reste, et Coolify ne le devine pas correctement : à la création d'une ressource il détecte « application PHP » et choisit Nixpacks, qui ignore le Dockerfile comme le compose, installe PHP 8.3 et lance
composer install --ignore-platform-reqs— dépendances de développement comprises. Le build échoue alors surLazy loading proxies require PHP 8.4 or higher, une erreur qui ne dit rien du vrai problème : ce n'est pas le bon constructeur qui tourne.
- Nouvelle ressource → Docker Compose (surtout pas Nixpacks), dépôt
ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git, branchemain. Sur une ressource existante : Configuration → General → Build Pack. - Fichier compose :
compose.prod.yaml. Ne pas laissercompose.yaml, que Coolify propose par défaut — Docker y fusionneraitcompose.override.yaml. - Renseigner les variables du tableau ci-dessus dans l'onglet Environment.
- Poser le domaine sur le service
php, et sur lui seul. Avec le build pack Docker Compose, Coolify liste chaque service et attend le domaine sur celui qui répond en HTTP — pas au niveau de la ressource. Un domaine posé ailleurs laissephpsans route, et le proxy répond 403 à un domaine qu'il ne connaît pas. - Déployer, puis lancer la commande d'amorçage ci-dessus depuis le terminal de la ressource.
Le fichier ne déclare pas SERVICE_FQDN_PHP_80. Cette variable magique
demande à Coolify de générer un domaine en …sslip.io : pratique pour un modèle
en un clic, mais elle entre en concurrence avec un domaine renseigné à la main,
et l'on se retrouve avec deux routes pour un même service dont une seule
correspond au DNS réel.
Diagnostiquer un 403
Un 403 sur le domaine vient presque toujours du proxy, pas de
l'application : celle-ci ne renvoie jamais 403 sur /, elle sert le
back-office. Trois vérifications, dans cet ordre :
# 1. La pile tourne-t-elle vraiment ? Les quatre services doivent être « running ».
docker ps --filter name=php --filter name=worker --filter name=database
# 2. L'application répond-elle DANS le réseau, proxy contourné ?
docker exec <conteneur-php> curl -sS -o /dev/null -w '%{http_code}\n' http://localhost/api/v1/health
# 3. Le proxy connaît-il le domaine ?
docker exec coolify-proxy cat /traefik/dynamic/*.yaml | grep -i tqslator
Si 2 répond 200 et que 3 ne trouve rien, le problème est le routage : le
domaine n'est pas posé sur le service php.
Le TLS est terminé par le proxy de Coolify ; Caddy a donc auto_https off, et
SYMFONY_TRUSTED_PROXIES est renseigné pour que Symfony lise
X-Forwarded-Proto — sans quoi les liens d'invitation partiraient en http://
depuis un site en https://.
Aucun port n'est publié sur l'hôte : le proxy atteint les conteneurs par le
réseau interne. Sur une VM sans Coolify, retirer SERVICE_FQDN_PHP_80 et
ajouter ports: ['80:80'] au service php.
Ce que le déploiement fait tout seul
- Migrations — appliquées au démarrage par le service
php, et par lui seul. Leworkerattend que le schéma soit à jour avant de consommer, sinon il polluerait les journaux du premier déploiement avec des erreurs qui n'en sont pas. - Cache applicatif — figé au build, jamais régénéré au démarrage : deux conteneurs issus de la même image doivent être identiques.
Volumes à conserver
db_data et app_storage. Le second porte les captures d'écran de contexte et
les pièces jointes — le seul état applicatif hors base.
Développement
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:diffproposera de supprimeridx_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.platformdanscomposer.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