Aller au contenu

Dev Container

Référence factuelle du Dev Container Kirexo. Pour le pas-à-pas d'ouverture, voir le tutoriel Ouvrir Kirexo dans un Dev Container PHPStorm. Pour les choix d'isolation, voir l'explication Choix d'isolation du Dev Container.

Vue d'ensemble

Trois éléments structurent le Dev Container, le reste de cette page n'est qu'un inventaire factuel.

  1. Stack Docker Compose lancée par post-start.sh — au démarrage du conteneur, le script enchaîne sudo init-firewall.sh puis le supervisor FrankenPHP en arrière-plan (nohup … --watch, logs dans var/log/devcontainer/frankenphp.log — lisibles côté hôte ET côté conteneur, c'est le même fichier via bind-mount sur /app). Les services Postgres, Redis, RabbitMQ, Typesense, Gotenberg, Mailpit et mkdocs sont démarrés par Docker Compose en parallèle. Tu n'as rien à lancer manuellement pour servir l'application.
  2. Pare-feu iptables interneinit-firewall.sh restreint le trafic sortant du conteneur à une whitelist explicite de domaines (cf. Domaines whitelistés par le pare-feu plus bas). Tout le reste est rejeté.
  3. Claude Code en bypassPermissions — possible parce que les deux points précédents existent (filesystem isolé du host + sortie réseau filtrée). Cf. Choix d'isolation.

Pour démarrer concrètement, voir le tutoriel PHPStorm. Pour comprendre les choix, voir l'explication.

Fichiers

Fichier Rôle
.devcontainer/devcontainer.json Configuration principale lue par PHPStorm / VS Code. Empile compose.yaml, compose.override.yaml et compose.devcontainer.yaml ; pointe sur le service php ; déclare les commandes de cycle de vie et les extensions IDE. Aucune feature devcontainer n'est utilisée — Node et Claude Code sont embarqués directement dans le Dockerfile.
.devcontainer/compose.devcontainer.yaml Surcharge Docker Compose appliquée uniquement en mode devcontainer. Ajoute cap_add: NET_ADMIN, NET_RAW, désactive IPv6 (le pare-feu ne pose des règles que sur iptables v4), monte le home Claude Code de l'hôte et injecte KIREXO_INSIDE_CONTAINER=1. Xdebug n'est pas activé par défaut.
.devcontainer/baseline-domains.txt Source de vérité unique de la whitelist baseline du pare-feu. Format texte simple (une ligne = un FQDN exact, # pour commenter, lignes vides ignorées) commenté domaine par domaine. Lu côté bash par init-firewall.sh ET côté PHP par src/DevContainer/FirewallWhitelist.php (via parseDomainLines(), helper statique aligné sémantiquement avec firewall_parse_domain_lines côté bash). Pour ajouter un domaine partagé par toute l'équipe, éditer ce fichier puis lancer castor devcontainer:firewall-reload. Verrouillé pour Claude Code (cf. .claude/settings.json) — un Claude en bypassPermissions ne peut pas le modifier.
.devcontainer/init-firewall.sh Configure le pare-feu sortant (iptables + ipset + dnsmasq). Étape 0 — pré-validation : reconstruit la directive ipset=…/allowed-domains à partir de baseline-domains.txt + whitelist.local.txt puis la confronte à dnsmasq --test --conf-file=<temp> avant de flusher quoi que ce soit. Si la config est invalide (typo, wildcard, IP littérale glissée), le script exit 1 et le pare-feu actuel (avec son ipset alimenté) reste en place — pas de panne « tout sortant rejeté » coincée derrière un dnsmasq mort. Étapes 1 et suivantes : pose les policies DROP immédiatement après le flush — y compris ip6tables INPUT/OUTPUT/FORWARD DROP en ceinture de sécurité (les sysctls de compose.devcontainer.yaml désactivent déjà IPv6, ip6tables est belt-and-suspenders), alimente l'ipset à partir des deux fichiers, recharge dnsmasq, puis joue les smoke-tests : un contrôle négatif bloquant (example.com), des contrôles positifs non bloquants sur les domaines critiques de l'écosystème, et la résolution intra-stack (database, redis, …) bloquante. Lancé en sudo par post-start.sh ou via castor devcontainer:firewall-reload.
.devcontainer/init-firewall.lib.sh Bibliothèque de fonctions pures sourçables (parsing/validation FQDN, helper firewall_log, constante KIREXO_WORKSPACE) partagées par init-firewall.sh, post-start.sh, frankenphp-supervisor.sh et php-healthcheck.sh. Aucune fonction n'effectue d'I/O système — 100 % testable par bats (cf. tests/Bash/firewall/). Garde anti-double-source via marqueur _FIREWALL_LIB_LOADED. Verrouillée pour Claude Code ET bind-montée en lecture seule.
.devcontainer/post-create.sh Script lancé au postCreateCommand (création initiale uniquement). Deux étapes : (1) crée .claude/settings.local.json avec bypassPermissions s'il n'existe pas — sautée si KIREXO_CLAUDE_BYPASS=0 ; (2) amorce le projet via castor install (composer install + importmap:install + tailwind:build). En cas d'échec de castor install, le devcontainer s'ouvre quand même et le dev relance à la main. Ne touche pas au fichier AGENTS.md à la racine — celui-ci est géré par le composer plugin symfony/ai-mate-composer-plugin.
.devcontainer/post-start.sh Script unique exécuté au postStartCommand : active le pare-feu (sudo init-firewall.sh), crée var/log/devcontainer/ (idempotent, mkdir -p) puis démarre le supervisor FrankenPHP en arrière-plan via nohup … --watch, logs dans var/log/devcontainer/frankenphp.log (lisibles depuis l'hôte via le bind-mount /app). Idempotent — si le supervisor tourne déjà, le second lancement est sauté.
.devcontainer/firewall-healthcheck.sh Wrapper sudo read-only consulté par le healthcheck Docker du service php en mode devcontainer. Enchaîne trois vérifications bloquantes : policy OUTPUT DROP toujours posée (iptables -S OUTPUT | grep -q '^-P OUTPUT DROP'), ipset allowed-domains existe (ipset list -n allowed-domains), ipset alimenté (ipset list -t allowed-domainsNumber of entries: ≥ 1). Exit 0 si les trois passent, exit 1 au premier KO (le healthcheck Docker bascule alors en unhealthy). NOPASSWD via /etc/sudoers.d/init-firewall (cf. Convention sudo). Verrouillé pour Claude Code (cf. .claude/settings.json) ET bind-monté en lecture seule par compose.devcontainer.yaml — un Claude qui remplacerait ce script pourrait neutraliser la détection du drift.
.devcontainer/php-healthcheck.sh Script wrapper du healthcheck Docker du service php en mode devcontainer. Enchaîne les quatre vérifications historiques (dnsmasq, config ipset, policy OUTPUT DROP via firewall-healthcheck.sh, FrankenPHP sur :2019/metrics) puis, si seule la 4ᵉ échoue, déclenche une auto-relance de FrankenPHP via post-start.sh détaché (setsid -f) — voir Healthcheck du service php et Auto-relance de FrankenPHP. Verrouillé pour Claude Code (cf. .claude/settings.json) ET bind-monté en lecture seule par compose.devcontainer.yaml — un Claude qui remplacerait ce script pourrait neutraliser la détection ou détourner l'auto-relance vers une commande arbitraire.
.devcontainer/AGENTS.md Notes spécifiques au devcontainer pour les assistants IA (Claude Code, Copilot, Junie) qui tournent à l'intérieur. Lue explicitement par un agent qui veut ce contexte — pas de symlink racine.

Service attaché

Le devcontainer attache PHPStorm/VS Code au service php du compose Kirexo (FrankenPHP). Le workspaceFolder est /app, l'utilisateur courant app (UID/GID alignés sur l'hôte via USER_ID/GROUP_ID au build, cf. Variables de build Docker).

Lancement hors Castor (JetBrains Gateway, devcontainer CLI)

Castor injecte automatiquement USER_ID/GROUP_ID au build (via posix_getuid()/posix_getgid()). Un build déclenché hors Castor — typiquement JetBrains Gateway / PhpStorm qui lance docker compose directement — ne dispose pas de cette injection. Le défaut 1000:1000 versionné dans .env couvre ce cas, car Docker Compose ne lit que .env, jamais .env.local.

Si votre UID ou GID hôte n'est pas 1000, exportez USER_ID/GROUP_ID dans l'environnement de la session qui lance l'IDE — voir la procédure du tutoriel PHPStorm. À défaut, le user app du conteneur est réaligné sur 1000:1000 et les fichiers du bind-mount /app appartiennent au mauvais propriétaire côté hôte. La précédence complète des valeurs est documentée dans Précédence des valeurs UID/GID.

Cycle de vie

Hook devcontainer Commande Effet
postCreateCommand /app/.devcontainer/post-create.sh Joué uniquement à la création du conteneur. Deux étapes : (1) crée .claude/settings.local.json (si absent) avec bypassPermissions — active le mode sans-confirmation pour Claude Code en terminal, sous PHPStorm et sous VS Code (mécanisme unique, indépendant des settings IDE). Sautée si l'un des deux opt-out est actif : la variable KIREXO_CLAUDE_BYPASS=0 exportée côté hôte avant création (one-shot) ou le marqueur fichier .devcontainer/no-claude-bypass présent dans le projet (persistant, gitignored) — cf. Marqueurs fichier locaux. (2) amorce le projet via castor install (composer install + importmap:install + tailwind:build) et écrit un marqueur selon l'issue : .devcontainer/.bootstrap.ok (JSON {status, at}) en cas de succès, .devcontainer/.bootstrap.failed (JSON {status, exit_code, at}) en cas d'échec. Si l'étape échoue (réseau, contrainte composer…), postCreate n'avorte pas — le devcontainer s'ouvre et le dev relance manuellement (castor install, ou en dernier recours castor reinstall). Les deux marqueurs sont gitignored. Ne crée pas de symlink à la racine : le fichier AGENTS.md racine est géré par le composer plugin symfony/ai-mate-composer-plugin (régénéré à chaque composer install) — y superposer un symlink ferait conflit à chaque rebuild.
postStartCommand /app/.devcontainer/post-start.sh Active le pare-feu sortant puis démarre le supervisor FrankenPHP en arrière-plan (nohup /app/.devcontainer/frankenphp-supervisor.sh, le supervisor exécute lui-même frankenphp run --config /etc/caddy/Caddyfile --watch, logs dans var/log/devcontainer/frankenphp.log — bind-monté, accessible côté hôte). Relancé à chaque démarrage du conteneur, idempotent sur les deux étapes.

FrankenPHP dans le devcontainer

FrankenPHP n'est pas lancé par le CMD du Dockerfile quand on entre via le devcontainer : c'est post-start.sh qui le démarre en arrière-plan, après le pare-feu. Cela permet à PHPStorm/VS Code d'ouvrir un shell sur le conteneur sans que la commande de démarrage occupe le PID 1.

Aspect Valeur
Commande lancée nohup /app/.devcontainer/frankenphp-supervisor.sh >var/log/devcontainer/frankenphp.log 2>&1 & — le supervisor encapsule l'appel réel à frankenphp run --config /etc/caddy/Caddyfile --watch et le relance en cas de crash.
Rechargement à chaud --watch redémarre le worker à chaque modification dans /app — pas besoin de relancer manuellement après un changement de code PHP.
Logs var/log/devcontainer/frankenphp.log — même fichier côté hôte ET côté conteneur (bind-mount sur /app). À consulter avec tail -f var/log/devcontainer/frankenphp.log depuis n'importe quel terminal (hôte ou interne au conteneur). Le dossier var/log/devcontainer/ est créé par post-start.sh (idempotent, mkdir -p) ; /var/ est dans .gitignore, aucun risque de commit.
Idempotence post-start.sh détecte un FrankenPHP déjà en cours via pgrep -x frankenphp et sort sans rien refaire — évite un double-bind sur :80.

Caddyfile — skip_install_trust

Le bloc global du frankenphp/Caddyfile active la directive skip_install_trust, qui empêche Caddy de tenter d'installer sa CA racine locale dans le trust store du conteneur :

{
    skip_install_trust

    {$CADDY_GLOBAL_OPTIONS}

    frankenphp {
        {$FRANKENPHP_CONFIG}
    }
}

Sans cette directive, Caddy essaie d'écrire dans /usr/local/share/ca-certificates/ au démarrage — ce qui exige sudo, et le sudoers du devcontainer est volontairement strict (NOPASSWD limité au seul init-firewall.sh, cf. Convention sudo). Le démarrage échouait avec un warning sudo.

Conséquence : la CA racine de Caddy n'est pas installée dans le trust store du conteneur. Aucun client TLS interne n'en a besoin — le seul client TLS qui se connecte à FrankenPHP est le navigateur côté hôte, qui passe par castor cert:trust pour faire confiance au certificat. Cf. castor cert:trust.

Sans effet en production : Let's Encrypt n'utilise pas la PKI locale.

Node et Claude Code dans l'image

Node et Claude Code sont installés directement dans le stage frankenphp_dev du Dockerfile — pas via les features du devcontainer.json. Ce choix contourne un bug du pipeline JetBrains, qui génère un Dockerfile temporaire (.features.temp.dockerfile) pour empiler les features tout en réappliquant le target: frankenphp_dev du compose ; le Dockerfile temporaire ne contient pas ce stage et le build échoue avec target stage "frankenphp_dev" could not be found. Le rationale détaillé est dans l'explication.

Composant Source Version
Node + npm + npx Tarball officiel nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz NODE_VERSION=24.16.0 (ARG Dockerfile)
Claude Code npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} CLAUDE_CODE_VERSION=2.1.163 (ARG Dockerfile)

L'archive Node est vérifiée par sha256sum -c (ARG NODE_SHA256) avant tar -xJf dans /usr/local. Les binaires node, npm et npx atterrissent directement dans /usr/local/bin/ (via --strip-components=1) — pas de dossier intermédiaire à patcher dans le PATH.

Conséquences pratiques :

  • castor ts:check utilise directement le binaire node de l'image (pas de conteneur jetable, pas de socket Docker requis).
  • La configuration Claude Code (bypassPermissions) est appliquée via .claude/settings.local.json, créé par post-create.sh (cf. ligne postCreateCommand du tableau Cycle de vie) — mécanisme commun à PHPStorm et VS Code, indépendant des settings IDE.
  • Pour mettre à jour Node ou Claude Code, modifier l'ARG correspondant dans le Dockerfile puis rebuilder l'image. La cible castor devcontainer:upgrade-tools automatise la résolution de version, le calcul du SHA-256 et la substitution dans le Dockerfile (et dans cette page).

Capabilities Docker

Le service php reçoit deux capacités supplémentaires via compose.devcontainer.yaml :

Capability Pourquoi
NET_ADMIN init-firewall.sh manipule iptables et ipset — interdit sans cette capacité.
NET_RAW Requise par iptables sur certains noyaux Linux récents pour les règles state-tracking.

Aucune autre capacité n'est ajoutée. Le conteneur reste sans privilège élevé sur le filesystem hôte ni sur le démon Docker.

Durcissements de sécurité

Trois mécanismes complémentaires aux capabilities ferment chacun une voie d'escalade spécifique. Aucun ne se substitue aux autres — c'est leur empilement qui rend la surface d'attaque exploitable depuis l'intérieur du conteneur effectivement réduite. Le rationale détaillé est dans Choix d'isolation du Dev Container.

Durcissement Modèle de menace adressé
Surcharge RO sur les 3 fichiers sensibles de ${HOME}/.claude (.credentials.json, settings.json, CLAUDE.md) compose.devcontainer.yaml, section volumes Un Claude compromis depuis l'intérieur peut lire ces fichiers (le bind RW est conservé pour préserver l'historique de conversations) mais ne peut plus les réécrire pour exfiltrer des credentials, désactiver sa propre deny-list ou injecter des instructions globales hostiles. Cf. Pourquoi ${HOME}/.claude est monté.
Bind-mounts RO sur init-firewall.sh, init-firewall.lib.sh, baseline-domains.txt, firewall-healthcheck.sh, php-healthcheck.sh compose.devcontainer.yaml, section volumes Le sudoers NOPASSWD autorise app à exécuter ces scripts en root. Sans le RO, un sous-processus pourrait réécrire le script depuis le bind-mount RW /app puis attendre le prochain reload pour exécuter du code arbitraire en root. Le RO ferme cette escalade — la deny-list Claude reste la première barrière au niveau CLI. php-healthcheck.sh est exécuté en boucle par Docker (et peut déclencher une auto-relance) : le RO empêche un Claude compromis de détourner cette relance vers une commande arbitraire.
Healthcheck enrichi : 4 conditions chaînées dans php-healthcheck.sh + auto-relance FrankenPHP compose.devcontainer.yaml, bloc healthcheck du service php Détecte un drift silencieux du pare-feu (iptables -F manuel pour debug et oublié) que les trois conditions historiques laissaient passer, et rattrape un FrankenPHP non démarré (cas connu : PhpStorm s'attache à un conteneur existant et zappe postStartCommand). Cf. Healthcheck du service php.

Prérequis hôte pour la surcharge RO de ${HOME}/.claude

Docker, face à un bind-mount file dont la source est absente, crée un dossier vide à la place. Si l'un des trois fichiers sensibles n'existe pas côté hôte avant le démarrage du devcontainer, le bind RO produit un dossier vide à la place du fichier attendu et Claude Code part en erreur au boot (impossible de lire .credentials.json, settings.json ou CLAUDE.md).

À lancer une fois côté hôte si l'un des trois fichiers n'existe pas encore :

touch "${HOME}/.claude/.credentials.json" "${HOME}/.claude/settings.json" "${HOME}/.claude/CLAUDE.md"

Le tutoriel Ouvrir Kirexo dans un Dev Container PHPStorm intègre ce touch dans les prérequis.

Healthcheck du service php

compose.devcontainer.yaml surcharge le healthcheck défini par défaut dans le Dockerfile (qui ne valide que FrankenPHP via :2019/metrics) pour qu'il couvre aussi le pare-feu interne — sans quoi un conteneur peut être remonté « healthy » alors que dnsmasq est tombé, et les domaines whitelistés cessent d'être alimentés dans l'ipset au fil des résolutions DNS.

La logique a été déportée dans .devcontainer/php-healthcheck.sh — un script wrapper invoqué via test: ["CMD-SHELL", "/app/.devcontainer/php-healthcheck.sh"]. Trois raisons à ce script externe plutôt qu'un test inline :

  • L'enchaînement de quatre vérifications et d'une auto-relance conditionnelle (cf. Auto-relance de FrankenPHP) est devenu illisible en CMD-SHELL chaîné par &&.
  • Testable en isolation (tests/Bash/).
  • Une seule entrée à verrouiller (deny-list Claude + bind-mount RO) plutôt que la ligne YAML.

Le script enchaîne quatre conditions, dans l'ordre du moins coûteux au plus coûteux :

  1. pgrep -x dnsmasq — le résolveur tourne. Sans lui, plus aucun domaine n'est ajouté à l'ipset → le pare-feu rejette tout sortant après expiration du cache des IPs déjà résolues. Échec → exit 1 sans auto-relance : relancer init-firewall.sh n'est pas le job d'un healthcheck, ça reste manuel via castor devcontainer:firewall-reload quand le dev a constaté le drift.
  2. test -f /etc/dnsmasq.d/firewall-ipset.conf — la config a bien été générée par init-firewall.sh (lecture sans privilège). Détecte un cas où dnsmasq tourne avec une vieille config (ex. post-start.sh n'a pas rejoué le pare-feu après modification de .devcontainer/baseline-domains.txt). Échec → exit 1 sans auto-relance, même raison.
  3. sudo -n /app/.devcontainer/firewall-healthcheck.sh — wrapper read-only qui enchaîne trois vérifications du pare-feu sortant : policy OUTPUT DROP posée (iptables -S OUTPUT | grep -q '^-P OUTPUT DROP', détecte un iptables -F manuel et oublié), ipset allowed-domains existe (ipset list -n, détecte un ipset destroy manuel ou un init-firewall.sh interrompu en plein milieu), ipset alimenté (ipset list -tNumber of entries: ≥ 1, détecte un dnsmasq qui tournerait mais n'alimenterait plus l'ipset — la règle match-set deviendrait silencieusement muette). Sans ces checks, le pare-feu peut perdre tout ou partie de ses règles tandis que dnsmasq + config restent en place — le healthcheck d'origine restait vert sur ces drifts silencieux. Les commandes exigent root : plutôt qu'élargir le sudoers à un pattern générique (iptables -S * ou ipset list *), on encapsule l'ensemble exact des commandes dans le wrapper et on autorise NOPASSWD UNIQUEMENT le wrapper (cf. Convention sudo). Échec → exit 1 sans auto-relance, même raison.
  4. curl -fsI http://localhost:2019/metrics — FrankenPHP répond. Repris du healthcheck d'origine pour ne pas perdre la couverture applicative. Spécificité : si seul ce check échoue (donc 1, 2 et 3 sont OK), le script tente une auto-relance — voir Auto-relance de FrankenPHP ci-dessous.
Paramètre Valeur Pourquoi
start_period 90s Laisse postStartCommand enchaîner init-firewall.sh (~10 s avec smoke-tests) puis le démarrage de FrankenPHP en background — le premier hit :2019/metrics n'est pas instantané au premier démarrage.
interval 30s Cadence régulière une fois passé le start_period. Aligné sur RELAUNCH_MIN_INTERVAL du lockfile d'auto-relance pour qu'au plus un cycle déclenche une relance.
timeout 5s Marge confortable pour les quatre conditions enchaînées.
retries 3 Trois échecs consécutifs avant de basculer en unhealthy.

Conséquence pratique : castor doctor (côté hôte) et le --wait de docker compose up peuvent attendre que le devcontainer soit véritablement opérationnel — pare-feu inclus — et pas seulement que FrankenPHP serve une page.

Auto-relance de FrankenPHP

Quand seule la 4ᵉ condition échoue (les trois checks réseau sont OK mais FrankenPHP ne répond plus sur :2019/metrics), php-healthcheck.sh ne se contente pas de retourner exit 1 : il tente une récupération automatique sans attendre un redémarrage du conteneur.

Cas d'usage qui motive la feature. PhpStorm, quand il s'attache à un conteneur déjà démarré au lieu de le re-créer (typique après un docker compose up lancé à la main avant d'ouvrir l'IDE, ou après réouverture d'un projet sur un conteneur encore vivant), zappe le postStartCommand du devcontainer. Conséquence : post-start.sh n'est jamais joué, le supervisor FrankenPHP n'a jamais été démarré, et https://localhost reste muet jusqu'à un docker compose restart php manuel. L'auto-relance rattrape ce cas dès le cycle de healthcheck suivant.

Deux garde-fous encadrent la relance :

  1. Guard pgrep frankenphp-supervisor.sh — si le supervisor tourne déjà, c'est qu'il est en train d'appliquer sa propre boucle de backoff exponentiel (cf. frankenphp-supervisor.sh) après un crash. On ne fait rien : court-circuiter sa logique de bail-out (5 crashs consécutifs sans stabilisation) en relançant post-start.sh masquerait un bug applicatif persistant derrière une apparente résilience.
  2. Lockfile mtime-based /tmp/php-healthcheck.relaunch.lockRELAUNCH_MIN_INTERVAL=30s (aligné sur l'interval du healthcheck) garantit qu'au plus un cycle déclenche une relance. Évite que deux healthchecks consécutifs (et leur timeout de 5 s) déclenchent deux post-start.sh en parallèle si le premier n'a pas encore eu le temps de faire repartir FrankenPHP.

Mécanique de détachement : setsid -f. Le shell parent invoqué par le healthcheck Docker est tué juste après le retour de ce script. Sans précaution, le post-start.sh relancé hériterait du SIGTERM/SIGHUP de fin de healthcheck et serait killé avant d'avoir lancé le supervisor. setsid -f crée un nouveau session group et fork le child — il devient orphelin de la session du healthcheck et survit à la fin du cycle. C'est ce qui rend la relance fiable malgré la durée de vie très courte du process appelant.

Audit. Avant chaque tentative de relance, le script écrit une ligne dans var/log/devcontainer/frankenphp.log :

[2026-05-17T14:32:08+00:00] php-healthcheck: FrankenPHP down, supervisor absent — relance via post-start.sh

La sortie de post-start.sh (stdout + stderr) est également redirigée vers ce fichier. Pour diagnostiquer un cycle de relance qui se répéterait à l'identique :

tail -f var/log/devcontainer/frankenphp.log

Idempotence garantie côté post-start.sh. Le script relancé ré-enchaîne sudo init-firewall.sh (idempotent), puis lance le supervisor FrankenPHP en vérifiant via pgrep frankenphp-supervisor.sh qu'il n'y en a pas déjà un — double sécurité au cas où le lockfile aurait été contourné.

Variables d'environnement spécifiques

Définies dans compose.devcontainer.yaml :

Variable Valeur Rôle
KIREXO_INSIDE_CONTAINER 1 Indicateur lu par castor.php (is_inside_container()) pour bypasser le wrap docker compose exec php … quand on est déjà dans le conteneur.

Xdebug n'est pas activé par défaut

Le devcontainer n'active pas Xdebug. L'extension est installée dans l'image (cf. Dockerfile, stage frankenphp_dev) mais reste inerte tant qu'on ne pose pas XDEBUG_MODE. Pour brancher le step-debugger PHPStorm (Listen for Connections, port 9003) ou VS Code, décommentez XDEBUG_MODE=develop,debug et XDEBUG_CONFIG=client_host=host.docker.internal start_with_request=yes dans .env.local — le modèle .env.local.dist documente la procédure complète.

Variables lues côté hôte (avant création du devcontainer)

Certaines variables ne sont pas définies dans le compose ; elles doivent être exportées dans l'environnement côté hôte au moment où PHPStorm/VS Code crée le devcontainer. Les poser dans .env.local n'a pas d'effet (le fichier est lu trop tard, après postCreate).

Variable Valeurs Rôle
KIREXO_CLAUDE_BYPASS 1 (défaut implicite) ou 0 Opt-out one-shot de la création automatique de .claude/settings.local.json par post-create.sh. À 0, le bypassPermissions n'est pas activé : Claude Code repasse en mode confirmation natif. Utile pour un dev qui préfère valider chaque action sensible. À exporter dans le shell qui lance l'IDE (export KIREXO_CLAUDE_BYPASS=0 && phpstorm .) — pas dans .env.local. La variable doit être ré-exportée à chaque session ; pour un opt-out persistant, préférer le marqueur fichier ci-dessous.

Marqueurs fichier locaux

En complément des variables d'environnement, post-create.sh lit aussi certains fichiers présents dans le dépôt au moment de sa première exécution. Ce mécanisme survit aux rebuilds du devcontainer (le marqueur est dans le bind-mount du projet, pas dans l'image) et n'oblige pas à ré-exporter une variable à chaque ouverture de l'IDE.

Marqueur Effet
.devcontainer/no-claude-bypass Opt-out persistant de la création de .claude/settings.local.json — équivalent à KIREXO_CLAUDE_BYPASS=0 mais durable. Si le fichier existe (vide ou non, son contenu est ignoré), post-create.sh saute l'étape (1) et Claude Code repasse en mode confirmation natif. Le marqueur est gitignored : c'est une préférence locale par dev. Pour annuler l'opt-out, supprimer le fichier (rm .devcontainer/no-claude-bypass) puis recréer le devcontainer (ou rejouer post-create.sh). Les deux mécanismes (variable + marqueur) sont équivalents en effet ; si l'un OU l'autre est actif, le settings.local.json n'est pas créé.

Ports publiés sur l'hôte

Les ports sont publiés par compose.yaml + compose.override.yaml (le devcontainer ne les modifie pas). Ils restent accessibles depuis votre navigateur côté hôte.

Service Port hôte Variable de surcharge
FrankenPHP — HTTP 80 HTTP_PORT
FrankenPHP — HTTPS (TCP) 443 HTTPS_PORT
FrankenPHP — HTTP/3 (UDP) 443 HTTP3_PORT
Documentation mkdocs 8000 MKDOCS_PORT
Mailpit — UI 8025 MAILPIT_UI_PORT
Mailpit — SMTP 1025 MAILPIT_SMTP_PORT
RabbitMQ — AMQP 5672 RABBITMQ_PORT
RabbitMQ — Management 15672 RABBITMQ_MANAGEMENT_PORT
Typesense 8108 TYPESENSE_PORT
Gotenberg 3000 GOTENBERG_PORT
PostgreSQL 5432 POSTGRES_PORT

Pour la définition complète, voir Variables Docker Compose (dev uniquement).

Domaines whitelistés par le pare-feu

La baseline est définie dans .devcontainer/baseline-domains.txt (source de vérité unique, versionnée). init-firewall.sh lit ce fichier ligne par ligne, y ajoute les domaines de whitelist.local.txt si présent, et concatène le tout dans la directive ipset=/.../allowed-domains de dnsmasq. Le préfixe /domain/ matche le domaine et tous ses sous-domaines.

Liste effective (synchronisée avec le fichier baseline) :

Domaine Usage
gitlab.com Repositories GitLab + CLI glab + API releases.
anthropic.com API Claude Code.
claude.ai Console Claude.
sentry.io Telemetry Sentry (si activée).
statsig.com Feature flags utilisés par Claude Code.
registry.npmjs.org Registre npm.
packagist.org Registre Composer.
cdn.jsdelivr.net CDN — assets npm/Composer.
marketplace.visualstudio.com Marketplace d'extensions VS Code.
vscode.blob.core.windows.net Téléchargement des binaires VS Code Server.
update.code.visualstudio.com Update channel VS Code.
symfony.com Doc Symfony, UX, AI Mate, Flex.
php.net Doc PHP, manuel.
doctrine-project.org Doc Doctrine.
getcomposer.org Doc + résolveur Composer.
frankenphp.dev Doc FrankenPHP.
jolicode.com Castor, AutoMapper.

GitHub volontairement exclu

Le projet Kirexo est hébergé sur GitLab, pas sur GitHub. Les binaires statiques GitHub indispensables (Castor, glab) sont téléchargés au build de l'image via le réseau de l'hôte (Dockerfile), pas au runtime depuis le devcontainer. La cible castor devcontainer:upgrade-tools est explicitement restreinte à l'hôte (cf. assert_outside_container() dans castor.php). Voir Choix d'isolation du Dev Container pour le détail.

Override local (whitelist.local.txt)

init-firewall.sh lit aussi, si présent, le fichier .devcontainer/whitelist.local.txt — une ligne par domaine, # pour commenter, lignes vides ignorées. Le fichier est gitignored : il sert d'override personnel par dev sans modifier le script versionné. Un modèle versionné .devcontainer/whitelist.local.txt.dist documente le format et propose des exemples commentés ; copiez-le en whitelist.local.txt pour démarrer (cp .devcontainer/whitelist.local.txt.dist .devcontainer/whitelist.local.txt). Voir Whitelister un domaine pour l'usage.

Chaque ligne est validée contre la regex FQDN partagée avec FirewallWhitelist::DOMAIN_REGEX (src/DevContainer/FirewallWhitelist.php) — labels alphanumériques, pas de schéma, pas de wildcard, pas d'IP littérale, pas d'espace. Le comportement est tolérant : une ligne invalide est skipée avec un avertissement explicite (AVERTISSEMENT : ligne rejetée — format invalide) plutôt que d'avorter le script. Le récap final affiche Whitelist effective : N entrée(s) baseline + M entrée(s) locale(s).. Pour pré-valider avant écriture, utiliser castor devcontainer:whitelist-add qui rejette l'entrée invalide en amont. Symétriquement, castor devcontainer:doctor remonte explicitement les lignes invalides de whitelist.local.txt dans son rapport (plutôt que de laisser le dev les chercher dans /tmp/post-start.log).

Pour ajouter un domaine partagé par toute l'équipe, voir aussi Whitelister un domaine.

Plages réseau internes Docker autorisées

Le trafic entre conteneurs (Postgres, Redis, RabbitMQ, Typesense, Gotenberg, Mailpit, mkdocs) ne passe pas par la whitelist DNS — il est autorisé directement par CIDR :

CIDR Plage
172.30.0.0/24 Réseau Docker custom kirexo défini dans compose.override.yaml (ipam.config.subnet). C'est la seule plage CIDR ouverte en sortie par le pare-feu — un service écoutant sur 10.x ou 192.168.x côté hôte n'est plus joignable depuis l'intérieur du devcontainer.

TRUSTED_PROXIES=172.16.0.0/12 (cf. variables-environnement.md) couvre déjà 172.16.0.0172.31.255.255 — la plage 172.30.0.0/24 y est incluse, aucun ajustement n'est nécessaire côté Symfony.

Convention sudo

Le Dockerfile (stage frankenphp_dev) installe une entrée /etc/sudoers.d/init-firewall avec deux lignes NOPASSWD :

app ALL=(root) NOPASSWD: /app/.devcontainer/init-firewall.sh
app ALL=(root) NOPASSWD: /app/.devcontainer/firewall-healthcheck.sh

L'utilisateur app peut donc lancer uniquement ces deux scripts en root, sans mot de passe. Tout autre sudo … reste refusé. Cette entrée est inerte hors devcontainer (les scripts n'existent pas dans les autres contextes de build).

Script Pourquoi NOPASSWD
init-firewall.sh Manipule iptables/ipset/dnsmasq au démarrage du conteneur (et à chaque castor devcontainer:firewall-reload).
firewall-healthcheck.sh Wrapper read-only enchaînant trois vérifications du pare-feu sortant (policy OUTPUT DROP, existence de l'ipset allowed-domains, alimentation de l'ipset) lancé en boucle par le healthcheck Docker. Encapsulé en wrapper plutôt qu'autorisé via des patterns sudoers iptables -S * / ipset list * génériques : on ne laisse passer que les commandes embarquées dans le wrapper, et le wrapper est verrouillé contre modification (deny-list Claude + bind-mount RO).

Le fichier doit être en mode 0440 — sudo refuse silencieusement un sudoers avec d'autres permissions, ce qui mène à un échec de castor devcontainer:firewall-reload sans message clair. castor devcontainer:doctor vérifie explicitement cette permission dans son check « Sudoers init-firewall présent (chmod 0440) ».

Fichiers verrouillés pour Claude Code

.claude/settings.json interdit à Claude Code (même en bypassPermissions) de modifier les fichiers qui définissent l'isolation du devcontainer. Le but est explicite : un Claude qui pourrait éditer ces fichiers pourrait contourner son propre sandbox.

Fichier Rôle protégé
.devcontainer/init-firewall.sh Définit le pare-feu sortant.
.devcontainer/baseline-domains.txt Définit la whitelist baseline.
.devcontainer/firewall-healthcheck.sh Wrapper sudo read-only consulté par le healthcheck Docker (trois vérifications du pare-feu sortant — policy OUTPUT DROP, existence et alimentation de l'ipset allowed-domains) — un Claude qui le remplacerait par un exit 0 muet neutraliserait la détection des drifts.
.devcontainer/php-healthcheck.sh Wrapper du healthcheck Docker du service php — un Claude qui le remplacerait pourrait neutraliser la détection (exit 0 muet) ou détourner l'auto-relance vers une commande arbitraire (le script invoque setsid -f /app/.devcontainer/post-start.sh).
.devcontainer/post-create.sh Amorçage et création de .claude/settings.local.json.
.devcontainer/post-start.sh Démarre le pare-feu et FrankenPHP.
.devcontainer/frankenphp-supervisor.sh Supervisor FrankenPHP.
.devcontainer/mate-mcp.sh Wrapper Mate MCP.
.devcontainer/compose.devcontainer.yaml Capacités Docker, sysctls IPv6, bind-mounts.
.devcontainer/devcontainer.json Hooks de cycle de vie, plugins.
Dockerfile, compose.yaml, compose.override.yaml Image et stack.
.devcontainer/whitelist.local.txt Override personnel — Claude doit demander au dev d'ajouter une ligne.

Si Claude a besoin de modifier l'un de ces fichiers (rare, mais possible quand le développeur lui-même travaille sur l'isolation), le dev l'autorise ponctuellement via .claude/settings.local.json ou édite à la main. Pour ajouter un domaine à la whitelist baseline, Claude doit demander explicitement — voir Whitelister un domaine.

Outillage ajouté dans l'image

Le stage frankenphp_dev du Dockerfile ajoute, en plus du Castor déjà présent :

Paquet Rôle
sudo Permettre à app de lancer init-firewall.sh en root.
iptables Pose des règles de filtrage.
ipset Stockage de l'ensemble allowed-domains alimenté par dnsmasq.
dnsmasq Résolveur DNS qui pousse chaque IP résolue dans l'ipset.
jq Parsing JSON utilisé par les hooks Claude, la statusline et plusieurs jobs CI (security:composer-audit, tools:version-check, parsing de rapports gl-secret-detection-report.json / gl-container-scanning-report.json). Installé dans le stage frankenphp_base du Dockerfile — donc présent dans kirexo-base et kirexo-dev sans apk add à la volée côté CI.
iproute2 Détection de la passerelle Docker (ip route).
ca-certificates TLS sortant.
shellcheck Linter des scripts shell du devcontainer — utilisé par la cible castor lint:shell (elle-même intégrée à castor lint).
bash-completion Framework requis par les scripts de complétion Symfony Console (Castor inclus). Sans ce paquet, castor <Tab> échoue sur _get_comp_words_by_ref: command not found. Cf. Autocomplétion Castor.

Tous ces paquets sont installés dans un seul apt-get update && apt-get install (chromium + outillage devcontainer + shellcheck + bash-completion) pour maximiser l'usage du cache de layer Docker.

Binaire statique Version Provenance Vérification
castor v1.5.0 (ARG CASTOR_VERSION) https://github.com/jolicode/castor/releases/download/${CASTOR_VERSION}/castor.linux-amd64.phar SHA-256 via ARG CASTOR_SHA256, vérifié par sha256sum -c avant install.
glab 1.101.0 (ARG GLAB_VERSION) https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz SHA-256 via ARG GLAB_SHA256, vérifié sur l'archive avant tar -xzf.
node / npm / npx NODE_VERSION=22.22.3 (ARG Dockerfile) https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz SHA-256 via ARG NODE_SHA256, vérifié par sha256sum -c avant tar -xJf.

Le SHA-256 attendu est figé dans le Dockerfile au même titre que la version : ça protège contre un mirror compromis ou une release republished sous le même tag. La cible castor devcontainer:upgrade-tools bumpe la version et le hash en même temps.

claude-code n'a pas de pin SHA-256 manuel — npm valide nativement l'integrity SRI (sha512) publié par le registre, ce qui constitue déjà un check d'intégrité.

Pour mettre à jour Castor, glab, Claude Code ou la version Node, utilise castor devcontainer:upgrade-tools — la cible résout les latest (et pour Node, la dernière patch de la dernière majeure LTS active : détection LTS via le calendrier officiel nodejs/Release, résolution de la patch via nodejs.org/dist/index.json), lit le SHA-256 attendu depuis le SHASUMS256.txt officiel, affiche un diff et bumpe les ARG du Dockerfile en place.

Surveillance de l'image base FrankenPHP

Le Dockerfile fige aussi le tag de l'image base via une instruction FROM dunglas/frankenphp:1-php8.5pas un ARG, donc hors champ d'application du mécanisme de bump automatique de ToolsUpgrader. castor devcontainer:upgrade-tools et castor devcontainer:check-tools interrogent malgré tout l'API publique de Docker Hub (https://hub.docker.com/v2/repositories/dunglas/frankenphp/tags/?page_size=100&name=php8.) pour détecter un tag plus récent du même format <franken-major>-php<php-major>.<php-minor>.

Aspect Comportement
Détection fetch_frankenphp_drift() dans castor/devcontainer.php — curl + appel de ToolsUpgrader::selectFrankenphpCandidate() (logique pure isolée pour la couverture unitaire).
Critère de sélection Tuple (franken-major, php-major, php-minor) strictement supérieur au courant, à l'exclusion des « régressions PHP » (un bump franken-major qui dégraderait la majeure PHP — ex. 1-php8.52-php8.4 rejeté). Variantes -alpine et tags pinnés 1.12-php8.5 ignorés silencieusement.
upgrade-tools Affiche un warning non bloquant qui suggère le bump manuel. La modification de la ligne FROM reste à la charge du développeur (lire le changelog upstream, vérifier les breaking changes).
check-tools (--check) Le drift compte dans le exit 2. Permet de notifier en CI qu'un bump est dispo sans rien appliquer.
Couverture indirecte Surveiller l'image base couvre aussi les paquets apt et install-php-extensions livrés via cette base — un bump entraîne implicitement leur mise à jour.

En cas d'indisponibilité de Docker Hub (rate-limit, panne), fetch_frankenphp_drift() retourne null silencieusement plutôt que de bloquer upgrade-tools : c'est un check d'information, pas un test de santé.

Le user app a un home en /home/app (créé par le Dockerfile) — nécessaire pour Node, npm et Claude Code installés en global dans l'image, qui y stockent leur configuration, leur cache et leur historique.

Autocomplétion Castor

L'autocomplétion de la commande castor est activée system-wide dans le stage frankenphp_dev du Dockerfile. Toute session bash interactive ouverte dans le devcontainer en bénéficie automatiquement — aucune action n'est requise du développeur.

Aspect Valeur
Mécanisme Sourcing de /usr/share/bash-completion/bash_completion (paquet bash-completion) puis eval "$(castor completion bash)", dans cet ordre — le script Symfony Console s'appuie sur _get_comp_words_by_ref fourni par le framework.
Fichier touché /etc/bash.bashrc (sourcé par toutes les sessions bash interactives sur Debian).
Portée System-wide — indépendant du home de l'utilisateur app, survit aux rebuilds de l'image.
Shell couvert bash uniquement (le shell de connexion par défaut du user app).

castor completion est une commande native de Castor (Symfony Console) qui émet sur la sortie standard un script de complétion statique ne dépendant ni du CWD ni d'un castor.php présent. L'eval à chaque session interactive est donc sans effet de bord, y compris si le shell est ouvert en dehors d'un dossier projet.

Hors devcontainer (sur la machine hôte du développeur), l'autocomplétion n'est pas activée automatiquement — c'est délibéré : on ne modifie pas le shell du développeur sans son consentement. Pour l'activer côté hôte, voir le guide Activer l'autocomplétion Castor sur l'hôte.

Cibles Castor — comportement à l'intérieur

castor.php détecte le devcontainer principalement via la variable KIREXO_INSIDE_CONTAINER=1 (injectée par compose.devcontainer.yaml) — c'est la source de vérité partagée avec .devcontainer/mate-mcp.sh. Un fallback sur /.dockerenv est ajouté pour couvrir les exécutions hors mode devcontainer mais déjà dans le conteneur Docker, typiquement docker compose exec php castor … lancé depuis l'hôte : sans ce fallback, castor croit être sur l'hôte et retente un docker compose exec à l'intérieur du conteneur, qui échoue faute de socket Docker monté. La fonction is_inside_container() (cf. castor.php) renvoie true si l'un des deux signaux est présent.

Cibles qui refusent l'intérieur

Toute cible qui pilote Docker depuis l'extérieur appelle assert_outside_container() et termine en erreur (exit 1) avec un message explicite. Lancez-les depuis un terminal hôte.

Cible Raison
castor docker:up Démarrage de la stack — exige le démon Docker.
castor docker:down Arrêt — idem.
castor docker:build Build des images — idem.
castor docker:logs docker compose logs — idem (échoue indirectement via la fonction compose()).
castor docker:sh docker compose exec — idem.
castor cert:trust Extraction depuis le volume Caddy + installation dans le trust store hôte.
castor doctor Vérifie l'état des conteneurs depuis l'hôte (binaires hôte, plugin Compose, services up). Suggère castor devcontainer:doctor depuis l'intérieur.
castor docs:build docker compose build + docker compose run du service mkdocs. Suggère curl -sIf http://docs:8000/ depuis l'intérieur (check rapide).
castor devcontainer:upgrade-tools Télécharge depuis api.github.com et github.com pour calculer les SHA-256 — domaines non whitelistés par le pare-feu interne. À lancer depuis l'hôte qui n'a pas cette contrainte.

Cibles qui refusent l'extérieur

À l'inverse, certaines cibles ne tournent que depuis l'intérieur du devcontainer (et échouent explicitement depuis l'hôte).

Cible Raison
castor devcontainer:firewall-reload Wrapper sur sudo /app/.devcontainer/init-firewall.sh. Le script édite les iptables du conteneur et exige la capability NET_ADMIN ; depuis l'hôte, il toucherait iptables du système — refusé.
castor devcontainer:whitelist-add <domain> Append au fichier local .devcontainer/whitelist.local.txt (gitignored) puis sudo init-firewall.sh. Valide le format (FQDN — refuse URL, wildcard, IP, espace via FirewallWhitelist::DOMAIN_REGEX), refuse si le domaine est déjà couvert par la baseline (match exact OU sous-domaine d'une entrée baseline) ou déjà présent localement.
castor devcontainer:whitelist-remove <domain> Retire la ligne correspondante de .devcontainer/whitelist.local.txt (préserve commentaires et lignes vides) puis recharge le pare-feu. Ne touche pas à la baseline — pour retirer un domaine de la baseline versionnée, éditer .devcontainer/baseline-domains.txt à la main.
castor devcontainer:doctor Diagnostic interne — symétrique de castor doctor (hôte). Lit les marqueurs .devcontainer/.bootstrap.{ok,failed} (échec si .failed existe ou si aucun des deux n'existe — pas d'amorçage initial enregistré), vérifie pgrep dnsmasq, l'existence de l'ipset allowed-domains, la réponse HTTP de FrankenPHP sur 127.0.0.1, la résolution intra-stack (database, redis, rabbitmq, typesense, gotenberg, mailer) et les migrations Doctrine à jour (uniquement si database répond). Exit 1 au moindre KO.
castor devcontainer:frankenphp-restart Relance le supervisor FrankenPHP après un bail-out (MAX_CRASHES atteint) ou un kill manuel. Idempotent : tue d'abord le supervisor (pkill -f frankenphp-supervisor.sh) et FrankenPHP (pkill -f 'frankenphp run') pour éviter un double-bind sur :80, puis relance via nohup /app/.devcontainer/frankenphp-supervisor.sh >var/log/devcontainer/frankenphp.log 2>&1 &. Hors devcontainer : exit 1 — FrankenPHP est piloté par l'entrypoint Docker côté hôte (castor docker:up / docker:down).

Cibles qui s'adaptent

Cible Adaptation
castor cs:fix / phpstan / lint / lint:shell / test:unit / test:e2e / composer / console / php / cache:clear / tailwind:build / install / reinstall / messenger:consume / doctrine:* / fixtures:load / mate Exécution directe (pas de wrap docker compose exec). Cf. php_exec() dans castor.php.
castor ts:check Détecte le devcontainer et lance npx --yes -p typescript@5 tsc --noEmit -p tsconfig.json directement avec le binaire node embarqué dans l'image (cf. Node et Claude Code dans l'image). Hors devcontainer, démarre un conteneur node:22-alpine jetable.
castor test:coverage Idem test:unit, avec XDEBUG_MODE=coverage injecté en variable de process. Le preflight « stack healthy » de --with-e2e est skipé (pas de socket Docker).

Configuration IDE

VS Code

Le devcontainer.json ne contient aucun bloc customizations.vscode — seul customizations.jetbrains est défini. VS Code reste utilisable sans configuration spécifique :

  • Extensions — aucune n'est pré-installée par le devcontainer. À la première ouverture, le dev installe ce dont il a besoin depuis le Marketplace (déjà whitelisté par le pare-feu, cf. Domaines whitelistés par le pare-feu). En particulier anthropic.claude-code pour piloter Claude Code depuis l'IDE.
  • Mode bypassPermissions Claude Code — appliqué via .claude/settings.local.json créé par post-create.sh. Le mécanisme est identique côté PHPStorm et VS Code : ce n'est pas une option des settings IDE, c'est un fichier de configuration Claude Code à la racine du projet. Opt-out via KIREXO_CLAUDE_BYPASS=0 ou marqueur .devcontainer/no-claude-bypass (cf. Marqueurs fichier locaux).
  • Step-debugger Xdebug — aucune configuration launch n'est pré-déclarée dans le devcontainer.json. Pour activer Xdebug, suivre la procédure décrite dans Xdebug n'est pas activé par défaut (variables XDEBUG_MODE / XDEBUG_CONFIG dans .env.local, modèle dans .env.local.dist), puis ajouter une config launch.json côté VS Code à la convenance du dev.

PHPStorm

Définie dans customizations.jetbrains :

Élément Valeur
Backend PhpStorm
Plugins Voir le tableau ci-dessous.

Plugins déclarés

Les plugins sont déclarés dans customizations.jetbrains.plugins du devcontainer.json et sont chargés par le backend PhpStorm du devcontainer au démarrage.

La colonne Bundled indique si le plugin est livré avec PhpStorm (oui) ou s'il est téléchargé depuis le JetBrains Marketplace (non). Déclarer un plugin bundled n'installe rien de neuf mais garantit son activation dans le backend du devcontainer, même si un développeur l'a désactivé dans son profil JetBrains synchronisé.

PHP & Symfony

Plugin ID Rôle Bundled
com.intellij.lang.php Support PHP (parser, inspections, refactorings). Cœur de PhpStorm. oui
fr.adrienbrault.idea.symfony2plugin Symfony Support — services, routes, DIC, traductions, templates. non
de.espend.idea.php.toolbox PHP Toolbox — métadonnées additionnelles pour le support PHP (providers de types et de signatures). non
de.espend.idea.php.annotation PHP Annotations — utile pour les attributs Doctrine/Symfony. non

Templating & frontend

Plugin ID Rôle Bundled
com.jetbrains.twig Twig — coloration, complétion, navigation. Déclaré pour activation forcée. oui
com.intellij.tailwindcss Tailwind CSS — autocomplétion des classes utilitaires dans les templates Twig. oui

Infrastructure

Plugin ID Rôle Bundled
Docker Inspection des fichiers compose.yaml et Dockerfile. oui
com.intellij.database Console SQL Postgres et navigateur de schéma. oui

Docs & config

Plugin ID Rôle Bundled
org.intellij.plugins.markdown Édition de docs/, CLAUDE.md, Étapes/. oui
org.jetbrains.plugins.yaml Configuration Symfony, CI, mkdocs. oui

Vérifier un Plugin ID

Les IDs com.intellij.tailwindcss et Docker sont les IDs canoniques utilisés par JetBrains. Si un plugin ne se charge pas au démarrage du devcontainer, l'ID exact se vérifie via Settings → Plugins → Installed → clic droit → Copy Plugin ID sur le PhpStorm hôte.