No description
Find a file
Stephan Morand ad104c82d7 Rendre le déploiement possible : image de production, amorçage, langues
L'image de production existait mais n'était jamais partie. Quatre manques la
rendaient inexploitable, tous silencieux — l'application démarrait dans les
quatre cas.

Le front n'était pas dans l'image
    public/build et public/index.html sont gitignorés, et le Dockerfile n'avait
    pas d'étage Node. L'API répondait, chaque URL de l'interface renvoyait 503 :
    une panne partielle, donc plus déroutante qu'une panne franche. Étage
    app_frontend ajouté, avec une vérification au build qui échoue si le front
    manque.

Aucune migration n'était appliquée
    Seul l'entrypoint de dev le faisait. docker/entrypoint.prod.sh applique les
    migrations sous garde RUN_MIGRATIONS et refuse de démarrer sur un APP_SECRET
    vide, un DATABASE_URL absent ou un BACK_OFFICE_URL sur localhost — cette
    dernière valeur ne casse rien de visible, elle casse la réception des
    invitations, ailleurs, plusieurs jours plus tard.

Aucun moyen de créer le premier compte
    Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
    Les fixtures sont une dépendance de dev. Il ne restait que l'INSERT à la
    main. app:create-super-admin crée l'organisation et le premier compte, refuse
    une adresse connue, exige douze caractères.

Le référentiel de langues était vide
    Créé par les fixtures, donc absent en production. Sans langue source à
    choisir, aucun projet ne pouvait être créé. Passé en migration : c'est une
    donnée de référence, pas un jeu d'exemple.

Au passage : l'expéditeur des courriels était en dur sur no-reply@tq-slator.local,
qu'un relais SMTP réel rejette. Devenu MAILER_FROM.

compose.prod.yaml est un fichier distinct, et c'est le point qui compte : Docker
fusionne compose.override.yaml dès que le principal s'appelle compose.yaml, si
bien que Mailpit et les ports publiés seraient partis en production sans que
personne ne l'ait demandé.

Parcours vérifié sur une pile neuve : connexion, création de projet, plateforme,
push de clés, publication, clé API, fichier livré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:53:17 +02:00
assets Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
bin Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
config Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
docker Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
docs Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
frankenphp Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
migrations Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
public Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
src Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
templates Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
tests Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
.dockerignore Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
.editorconfig Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
.env Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
.env.dev Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
.env.test Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
.gitignore Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
.php-cs-fixer.dist.php Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
compose.override.yaml Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
compose.prod.yaml Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
compose.yaml Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
composer.json Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
composer.lock Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
Dockerfile Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
package-lock.json Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
package.json Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
phpstan.neon Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
phpunit.dist.xml Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
README.md Rendre le déploiement possible : image de production, amorçage, langues 2026-08-21 10:53:17 +02:00
symfony.lock Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
tsconfig.json Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00
vite.config.ts Socle complet de TQ-Slator : éditeur, API, CLI, administration 2026-08-21 08:16:05 +02:00

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
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é.

# 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)) == 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é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

  1. Nouvelle ressource → Docker Compose, dépôt ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git, branche main.
  2. Fichier compose : compose.prod.yaml. Ne pas laisser compose.yaml, que Coolify propose par défaut.
  3. Renseigner les variables du tableau ci-dessus dans l'onglet Environment.
  4. Attribuer le domaine au service php. SERVICE_FQDN_PHP_80 est déjà déclaré dans le fichier : Coolify y place le domaine et configure son proxy.
  5. Déployer, puis lancer la commande d'amorçage ci-dessus depuis le terminal de la ressource.

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. Le worker attend 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: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