PHP, Symfony & Laravel playground

Official registry Developer tools

Create shareable links to run Symfony, Laravel or plain PHP code in the browser, no install.

Install

Add this to your client's MCP configuration (Claude Desktop: claude_desktop_config.json; Cursor: .cursor/mcp.json; VS Code and others use the same "mcpServers" shape).

Remote (streamable-http)
Claude Code
claude mcp add --transport http php-symfony-laravel-playground 'https://apprendre.forelse.fr/mcp'
JSON configuration (Claude Desktop, Cursor, VS Code)
{
    "mcpServers": {
        "php-symfony-laravel-playground": {
            "type": "http",
            "url": "https://apprendre.forelse.fr/mcp"
        }
    }
}

Documentation

From the project's README on GitHub, refreshed weekly. Links open on GitHub.

Contents
  1. Forelse · apprendre — le moteur
  2. Architecture
  3. Démarrer en local
  4. L'atelier des auteurs
  5. Le mentor de l'apprenant
  6. Parcours payants
  7. Packs de contenu
  8. Thèmes
  9. Ajouter un environnement depuis un dépôt Git
  10. Pages publiques et référencement
  11. Sécurité
  12. Auto-héberger avec Docker
  13. Versionnage
  14. Licence

Forelse · apprendre — le moteur

Une plateforme pour apprendre à développer en codant, sans vidéo ni installation. L'apprenant écrit son code dans le navigateur, où tourne un vrai projet : PHP 8.5 compilé en WebAssembly pour Symfony et Laravel, et des simulateurs pour Docker et Nuxt. L'aperçu se met à jour en direct, et des tests automatiques valident chaque objectif. Rien n'est exécuté sur le serveur.

C'est le moteur de apprendre.forelse.fr, open source (AGPL-3.0) et auto-hébergeable. Les exercices n'en font pas partie : ils vivent dans des packs de contenu, que le moteur charge. Un pack de démonstration est fourni dans examples/packs/demo, et chacun peut écrire les siens. Les parcours de Forelse ne sont pas dans ce dépôt ; une école ou une entreprise peut les utiliser sur sa propre instance, sur devis (en savoir plus).

Architecture

platform/         Application Symfony 8.1 : pages, comptes, progression, API, content:check
    src/Content/Framework/   Ce que le moteur sait de chaque framework (un profil par runtime)
playground/       Îlot TypeScript : runtime PHP WebAssembly, éditeur Monaco, aperçu isolé
packages/           Ce qui peut vivre hors du moteur : contrat et simulateurs
  runtime-contract/  Ce qu'un runtime doit remplir, et ce que le moteur lui fournit (types seuls)
  simulateur-nuxt/   Le runtime Nuxt : simulateur, worker, greffons de build, lanceur de tests
  simulateur-docker/ Le simulateur Docker, bibliothèque PHP du runtime php-wasm
environments/     Projets de base dans lesquels s'exécutent les exercices, et de quoi les empaqueter
                  (bin/build-env.sh) — destiné à devenir son propre dépôt, voir environments/README.md
tools/            Simulateurs Docker (docker-sim) et Nuxt (nuxt-sim), publication d'une version
auto-hebergement/ Guide, compose.yaml et .env.example d'une instance auto-hébergée
deploy/           production et préproduction de l'instance de Forelse, derrière un proxy commun (deploy/README.md)
examples/packs/   Pack de démonstration (format de contenu, tests du moteur)
examples/themes/  Exemple de thème d'instance (marque blanche) : theme.yaml commenté et ses images

Dans le navigateur :

Page exercice (plateforme) ── worker PHP (php-wasm) : instance « aperçu » + instance « tests »
      │  postMessage (origine vérifiée)
      ▼
Relais /sandbox (origine bac à sable) ── Service Worker ── iframe d'aperçu (code de l'apprenant)

Le code de l'apprenant n'est jamais exécuté sur le serveur : PHP tourne dans le bac à sable WebAssembly du navigateur, sans accès au disque ni au réseau. Le playground offre aussi une console du projet (bin/console pour Symfony : Doctrine, debug:router… ; php artisan pour Laravel : migrate, route:list…), exécutée dans ce même bac à sable.

Le bac à sable (/bac-a-sable, /en/playground) a aussi un serveur MCP sur /mcp (HTTP, sans compte), pour les assistants IA (Claude, ChatGPT, Cursor…) : get_starter (projet de départ, chemins modifiables, paquets), create_sandbox (fabrique le lien partagé, rien n'est stocké) et read_sandbox (relit un lien). Voir App\Mcp\PlaygroundTools ; le lien suit le format de playground/src/app/scratch.ts (App\Scratch\ScratchLink). Quota par adresse IP : limiteur mcp. Chaque appel d'outil est un événement Umami envoyé par le serveur (mcp-depart, mcp-lien-cree, mcp-lien-lu : framework, langue, client MCP, jamais le code), voir App\Mcp\McpAnalytics ; les visites venues de ces liens portent utm_source=mcp. Ajout dans Claude Code : claude mcp add --transport http php-playground https://apprendre.forelse.fr/mcp. Documentation publique : /en/playground/ai ; fiche du registre MCP : mcp/server.json.

Démarrer en local

Prérequis : PHP 8.5 (avec pdo_pgsql), Composer, Node.js 22+, PostgreSQL 15+.

Créez une fois le rôle attendu par platform/.env (ou adaptez DATABASE_URL dans platform/.env.local) :

psql -d postgres -c "CREATE ROLE app LOGIN PASSWORD 'app' CREATEDB"
make install   # dépendances, environnements d'exécution, bases PostgreSQL (formation, formation_test)
make dev       # plateforme http://localhost:8000, bac à sable http://127.0.0.1:8001, Vite, Mailpit http://localhost:8025

Ouvrez http://localhost:8000. Pour charger d'autres packs, dans platform/.env.local :

CONTENT_PACKS_PATHS="%kernel.project_dir%/../examples/packs,/chemin/vers/mes-packs"

Tests : make test. Vérification des packs : make check.

make build écrase platform/public/build/.vite/entrypoints.json : la plateforme sert alors les fichiers compilés, même si Vite tourne encore. Après un build, relancez Vite (make dev-vite) pour retrouver le rechargement à chaud — sinon vos modifications front ne s'affichent pas.

Les serveurs de développement tournent avec -d xdebug.mode=off : Xdebug fait planter le serveur interne de PHP (segfault) sur certaines pages, et ralentit tout. content:check fait de même.

L'atelier des auteurs

/atelier écrit les exercices d'un pack depuis le navigateur : les fichiers tels qu'ils sont sur le disque (exercise.yaml, instructions.md, starter/, solution/, tests/), un bouton Vérifier qui lance content:check et affiche son verdict, et un lien pour jouer l'exercice comme un apprenant.

L'atelier écrit sur le disque et exécute le PHP du pack : il est réservé au rôle ROLE_AUTEUR.

⚠️ Sur des packs modifiables, ROLE_AUTEUR est un rôle d'administrateur. Vérifier lance PHPUnit sur les tests que l'auteur vient d'écrire, avec l'utilisateur système de la plateforme : un test peut lire les sessions, APP_SECRET et les variables d'environnement du serveur (clés Stripe et Anthropic, base). Ne le donnez qu'à qui pourrait administrer la machine. Les compose.yaml montent les packs en lecture seule : l'atelier y reste consultable, mais n'écrit rien, et Vérifier ne lance que ce que le dépôt de contenu contient déjà — le même code que sa CI. On écrit alors dans le dépôt de contenu, sur son poste ; ne retirez pas le :ro d'une instance en production.

bin/console app:auteur vous@example.com            # donner le droit
bin/console app:auteur vous@example.com --retirer  # le retirer

Un exercice se supprime depuis son éditeur (dossier et ligne de track.yaml), sauf s'il sert de base à un autre : le fil rouge se casserait.

Un parcours dont le dossier est en lecture seule reste consultable, mais n'est pas modifiable.

Chaque chapitre a sa fiche de cours (voir « Packs de contenu »), éditable depuis l'atelier : markdown à gauche, à droite l'aperçu rendu par le serveur, donc exactement ce que verra l'apprenant. Squelette assemble un point de départ à partir des exercices du chapitre, Rédiger avec l'IA demande un brouillon complet au modèle ; les deux sont proposés, jamais enregistrés sans relecture. Enregistrer une fiche vide la retire du chapitre.

Génération assistée (facultative)

Avec une clé d'API, l'atelier peut proposer un brouillon d'exercice à partir d'un sujet (« tester un voter avec des stubs »), dans l'univers et le style du parcours : consignes, fichiers de départ, solution, tests, indices, liens de documentation. Après un échec de content:check, un bouton Corriger avec l'IA lui renvoie le rapport et propose une correction.

Avec la même clé, un bouton Post LinkedIn apparaît sur la page d'un parcours et sur l'éditeur d'un exercice de Pratique — les deux objets du contenu qui ont une page publique à partager. Il ouvre /atelier/<parcours>/post, où le modèle écrit trois brouillons sous trois angles (annonce, coulisses, pédagogique) à partir du contenu déjà écrit : résumé, chapitres, notions, consignes. Chaque brouillon se relit et se retouche sur place, avec le compte de caractères que LinkedIn impose (3 000 au total, ~200 avant « …voir plus »), puis se copie. Rien n'est publié — l'atelier ne parle jamais à LinkedIn — et rien n'est enregistré : un post vit dans LinkedIn, pas dans le pack.

L'éditeur d'un exercice de Pratique mène aussi à sa version anglaise (/atelier/pratique/<exercice>/anglais) : le même éditeur, sur les fichiers de i18n/en/ (voir « Traduction »). Avec la clé, Traduire avec l'IA propose la traduction entière, en partant de celle qui existe : exercise.yaml réduit aux clés traduisibles, consignes, fichiers de code qui changent, mutants, et l'empreinte source calculée par la plateforme. Rien n'est enregistré avant que l'auteur relise ; Vérifier lance content:check sur la version anglaise. Une traduction en retard sur l'exercice français est signalée dans l'éditeur, avec l'empreinte à recopier.

# platform/.env.local
ANTHROPIC_API_KEY=sk-ant-…
AI_MODEL=claude-sonnet-5        # facultatif
AI_MODEL_POST=claude-opus-5     # facultatif : les posts LinkedIn seulement, vide = AI_MODEL

AI_MODEL vaut pour tout ce qui appelle le modèle ; AI_MODEL_POST ne vaut que pour les brouillons de post. Écrire un post n'est pas relire du code : on peut vouloir un modèle plus fort là où le texte part sous son propre nom, sans payer ce modèle sur chaque revue de code du mentor.

La même clé sert à content:lesson-draft --ia, qui rédige un brouillon de fiche de cours (voir « Packs de contenu »). Sans clé, l'atelier fonctionne à l'identique et ces boutons n'apparaissent pas. Un brouillon reste un brouillon : rien n'est enregistré sans relecture, et c'est content:check qui tranche — tests rouges au départ, verts avec la solution.

Le mentor de l'apprenant

Avec la même clé d'API, l'apprenant connecté dispose d'un mentor dans le playground, qui intervient là où un bon mentor interviendrait, jamais à sa place :

  • Une revue de code, sur demande, quand les tests passent : un regard sur ce qui pourrait être plus idiomatique, plus lisible ou plus sûr, en quatre points au plus, avec la solution de référence comme point de comparaison (jamais citée). Elle est conservée avec la progression.
  • Des erreurs expliquées : sur une page d'exception de l'aperçu, des tests qui plantent ou une commande bin/console en échec, un bouton « Expliquer l'erreur » traduit l'exception en cause probable et en piste (où regarder, quoi vérifier), sans donner la correction.

Les indices, eux, sont écrits par l'auteur dans exercise.yaml et ne coûtent rien. Le mentor est réservé aux comptes et limité à 40 appels par heure et par apprenant (config/packages/rate_limiter.yaml) : chaque appel se paie. Sans clé, les boutons n'apparaissent pas.

La solution de référence est consultable par un apprenant connecté (bouton « Solution »), à côté de son code, en lecture seule. Il est prévenu avant : l'exercice ne rapportera alors pas d'XP, même réussi ensuite avec son propre code. Un invité n'y a pas accès : les solutions font partie du contenu.

Parcours payants

Une seule question décide de ce qu'un apprenant ouvre : a-t-il accès à ce chapitre ? (TrackAccessChecker, utilisé par les pages, les API de l'exercice, de la progression et du mentor, les fiches et le livret).

  1. Sans compte, rien ne se joue : la page d'un exercice reste publique et lisible (consigne, notions), mais l'éditeur, l'exécution et les tests demandent un compte.
  2. Le premier chapitre d'un parcours public est ouvert, gratuitement, à tout compte connecté.
  3. Un administrateur ou un auteur ouvre tout.
  4. Un parcours sans tarif, ou à 0 €, est ouvert à tout compte : une instance qui ne fixe aucun prix reste gratuite.
  5. Un chef de cohorte ouvre les parcours de ses cohortes.
  6. Sinon, il faut un accès actif : acheté, ouvert par une cohorte ou offert (/admin → Accès). Un accès expiré ou révoqué garde la progression, qu'un nouvel accès retrouve intacte.

Les prix (TTC, avec prix fondateur limité par une date ou un quota) se fixent dans /admin → Tarifs ou avec bin/console app:tarif. L'achat passe par Stripe Checkout et Stripe Tax (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_TAX_CODE) ; seul le webhook /paiement/stripe/webhook ouvre l'accès, à vie. Les événements reçus sont journalisés et se rejouent avec bin/console app:stripe:rejouer. Un administrateur rembourse depuis /admin → Achats (l'accès est révoqué).

Une cohorte est financée par l'établissement (ses apprenants reçoivent l'accès aux parcours choisis, aux dates de la cohorte ; l'estimation du devis se règle dans config/services.yaml, app.cohort_quote.*) ou par ses apprenants (chacun achète, au tarif de la cohorte s'il est fixé).

Un chef de cohorte peut organiser des soutenances de fin de parcours (/cohorte/{id} → Soutenances) : il ouvre des créneaux en ligne (lien de visio) ou en présentiel (adresse, salle), un apprenant qui a réussi tous les exercices du parcours en réserve un depuis la page du parcours et reçoit sa convocation avec une invitation d'agenda (.ics). L'évaluateur (chef de la cohorte ou administrateur) lit le dossier de l'apprenant (code rendu, indices, solutions consultées, revue du mentor), remplit la grille (un niveau par chapitre) et donne le résultat, envoyé par email. Les résultats s'exportent en CSV.

Mise à jour d'une instance vers la 0.8 : après la migration, lancer une fois bin/console app:acces:initialiser (les cohortes et les apprenants existants gardent ce qu'ils avaient), puis seulement fixer des prix.

Packs de contenu

<pack>/pack.yaml                       id, titre, version, licence, moteur attendu, parcours
<pack>/tracks/<parcours>/track.yaml    environnement, chapitres → exercices
<pack>/tracks/<parcours>/about.md      présentation du parcours (Markdown, facultative) : ce qu'on y apprend, pour qui
<pack>/tracks/<parcours>/chapters/<chapitre>/
    lesson.md         fiche de cours de fin de chapitre (Markdown, facultative)
<pack>/tracks/<parcours>/exercises/<exercice>/
    exercise.yaml     titre, XP, accès, fichiers éditables / en lecture seule, objectifs, indices
    instructions.md   consignes (Markdown)
    starter/          fichiers de départ, superposés à l'environnement
    tests/            tests PHPUnit cachés ; chaque objectif = une méthode de test
    solution/         solution de référence (jamais envoyée au navigateur, sauf en dev)
<pack>/practice/<exercice>/            exercice de Pratique, hors parcours (même contenu qu'un exercice)
<pack>/versions/<version>.md           intro d'une page de nouveautés (Markdown, facultative)
<pack>/notions/<notion>.md             intro d'une page de notion (Markdown, facultative ; sans elle, la page ne s'indexe pas)
<pack>/notions/en/<notion>.md          intro d'une page de notion anglaise (/en/concepts/<notion>, nommée par son slug anglais)
<pack>/notions/themes.yaml             rangement de l'index /notions : par framework, des thèmes et leurs notions (facultatif)
<pack>/notions/en/themes.yaml          rangement de l'index /en/concepts, par les libellés anglais des notions (facultatif)
<pack>/articles/<slug>.md              article du blog (en-tête YAML, puis Markdown)
<pack>/articles/en/<slug>.md           sa traduction anglaise (même format, plus translation_of)
  • duration: (dans exercise.yaml, facultative) : la durée estimée de l'exercice, en minutes, pour le public du parcours (duration: 25). La carte du parcours l'affiche par exercice, par chapitre (« ≈ 3 h 15 ») et pour le parcours (« environ 46 h de pratique »), ainsi que l'accueil et le JSON-LD du cours (timeRequired). Un chapitre ou un parcours dont un exercice n'a pas de durée n'en affiche pas : une somme partielle promettrait trop peu.
  • Pratique (<pack>/practice/<exercice>/) : de courts exercices à part des parcours, pour se servir d'une fonctionnalité d'un framework, souvent une nouveauté d'une version, parfois un point précis. Pas de chapitre, pas de fiche, pas d'XP, pas d'histoire : le code de départ est écrit à l'ancienne, les tests vérifient le comportement et l'usage de la fonctionnalité (par réflexion, par exemple). Le moteur découvre les dossiers de practice/ tout seul, sans liste dans pack.yaml. exercise.yaml garde son format, avec en plus :

    • environment: (obligatoire : il n'y a pas de parcours dont l'hériter) ;
    • published: AAAA-MM-JJ (obligatoire) : la liste /pratique va du plus récent au plus ancien. Une date à venir programme l'exercice : visible des seuls administrateurs (badge « Programmé »), il est publié automatiquement ce jour-là ;
    • summary: (obligatoire), une phrase affichée dans la liste ;
    • version: '8.1' (facultative, entre guillemets) : la version du framework qui apporte la fonctionnalité ; elle affiche le badge « Nouveauté » et content:check échoue si l'environnement a une version plus ancienne (lue dans son composer.lock) ;
    • pull_request: (URL https, facultative), proposée à la réussite ;
    • visibility: admin (facultative), comme pour un parcours : un exercice en préparation.

    access, base et xp y sont refusés : la Pratique demande toujours un compte et ne rapporte pas d'XP. Les identifiants sont uniques parmi les exercices de Pratique de tous les packs, et un parcours ne peut pas s'appeler pratique. La liste /pratique est publique, filtrable par framework, par notion (concepts:) et par nouveauté ; l'exercice se joue sur /pratique/<exercice>. À la réussite, le playground propose un « Avant / après » (le code de départ face à celui de l'apprenant). L'atelier crée un exercice de Pratique en préparation (visibility: admin) dans un pack modifiable.

    Traduction (<exercice>/i18n/en/) : l'exercice en anglais, joué sur /en/practice/<exercice>. La Pratique anglaise ne montre que les exercices traduits ; un exercice non traduit y répond 404. Le dossier contient :

    • exercise.yaml, avec les seules clés traduites : title, summary, objectives, hints, requests, docs, mutants (obligatoires dès que l'exercice français les a, sinon le texte français s'afficherait), concepts, preview et readonly (facultatives ; readonly quand un fichier montré en lecture seule a son équivalent dans l'environnement de la langue, lang/en/validation.php au lieu de lang/fr/validation.php). Les mutants traduits gardent leurs ids, que les objectifs citent ; leurs search visent les lignes du code traduit. Le reste (environnement, fichiers, date, version…) vient de l'exercice français ; une autre clé est refusée ;
    • instructions.md, la consigne complète ;
    • au besoin, des fichiers de starter/, solution/ et tests/ qui remplacent ceux de l'exercice français au même chemin, quand le code contient du français (messages, noms de méthodes de test). Un chemin que l'exercice français n'a pas est refusé.

    content:check vérifie chaque traduction comme un exercice à part (pratique/<exercice> [en]). La clé source recopie l'empreinte de l'exercice français : quand il change, content:check signale la traduction en retard et donne la nouvelle empreinte à recopier une fois la traduction à jour. Une réussite en anglais est revérifiée avec les tests anglais. Les pages de notions et de nouveautés restent en français : la page anglaise n'y renvoie pas.

    Traduction d'un parcours : chaque exercice se traduit comme en Pratique (<exercice>/i18n/en/, sans summary), et le parcours lui-même dans <parcours>/i18n/en/ :

    • track.yaml, avec title, description et chapters, qui associe à l'id de chaque chapitre son title (et sa description si le chapitre français en a une) ; source recopie l'empreinte du parcours français (track.yaml, fiches de cours et présentation) ;
    • chapters/<chapitre>/lesson.md, la fiche de cours traduite de chaque chapitre qui en a une ;
    • about.md, la présentation traduite, si le parcours français en a une.

    Un parcours ne paraît en anglais qu'entièrement traduit : il ne fait pas passer l'apprenant d'une langue à l'autre. Ses exercices peuvent se traduire avant lui, un par un : content:check les vérifie déjà. Une traduction devenue incomplète (un exercice ou un chapitre ajouté en français) ne bloque rien : le parcours anglais disparaît, et content:check dit ce qui manque. Le parcours de démo decouverte est traduit en exemple. Un parcours traduit a ses pages sous /en/tracks/<parcours> ; ailleurs, l'adresse anglaise renvoie au français.

  • Blog (<pack>/articles/<slug>.md) : des articles sur les coulisses du contenu et du moteur, publiés sur /blog/<slug>. Le nom du fichier est l'adresse de l'article (minuscules, chiffres et tirets). Le fichier commence par un en-tête YAML entre deux lignes ---, suivi du Markdown (rendu comme une fiche de cours : HTML brut supprimé, code coloré ; un # Titre en tête est retiré, la page affiche le sien) :

    ---
    title: Faire tourner Symfony dans le navigateur       # obligatoire : le h1 et le title de la page
    description: Comment un vrai projet Symfony tourne…   # obligatoire, sans HTML : la meta description
    published: 2026-10-06                                 # obligatoire ; une date à venir programme l'article
    updated: 2026-10-20                                   # facultative : dernière révision du fond
    visibility: admin                                     # facultative : en préparation
    practice: laravel-13-26-read-through                  # facultative : l'exercice de Pratique dont part l'article
    course: symfony-pour-dev-php                          # facultative : le parcours proposé en fin d'article
    author: grace-hopper                                  # facultative : un des « authors » du thème (sinon « person »)
    tags: [Symfony, Sécurité]                             # facultative : les sujets, filtres de la liste
    ---

    Toute autre clé est refusée (une faute de frappe ne passe pas inaperçue). Un article programmé ou en préparation n'est ni dans la liste /blog ni dans le sitemap ; seuls les administrateurs le lisent à son adresse. Une instance dont aucun pack n'a d'article paru n'a pas de blog : /blog répond 404 et le pied de page n'y mène pas. Chaque article porte ses données structurées (BlogPosting) ; sa date dans le sitemap est updated, à défaut published.

    La liste met l'article le plus récent à la une, puis range les autres par mois. Deux articles du même jour se départagent par une heure (published: 2026-10-28 14:00:00, en UTC ; sans heure, minuit) : le plus tardif passe devant. L'heure ne fait qu'ordonner, l'article paraît dès le matin de son jour. Les sujets (tags:, des libellés courts, tels qu'ils s'affichent) donnent les filtres de la liste, du plus fourni au moins fourni : /blog?sujet=securite ne montre que les articles « Sécurité » (page suivie, non indexée). Une traduction écrit ses propres sujets, dans sa langue. Dans le flux RSS, les sujets deviennent des <category> : dev.to, qui importe le flux, en fait les tags de l'article (quatre au plus, sans espace ni accent).

    Chaque article a son image, dessinée par le moteur comme celles de la Pratique (share_card du thème) : « Blog · <premier sujet> », le titre et le site, en 1200 × 630 sur /blog/<slug>/image-de-partage. Elle sert d'aperçu sur les réseaux, d'image des données structurées et de vignette dans le flux RSS (enclosure et media:content). dev.to ignore les images du flux : collez l'adresse de l'image dans « Cover image » avant de publier le brouillon.

    Chaque article se termine par une invitation à essayer dans le navigateur. Avec practice: (l'identifiant d'un exercice de Pratique, qui doit exister), elle mène à cet exercice, et une ligne sous le titre y invite aussi ; sans, elle mène à la liste des exercices de Pratique. Une traduction sans practice: reprend celle de l'article français ; si l'exercice n'est pas traduit, elle invite à la Pratique anglaise. Les clics se comptent dans Umami (événements article-exercice, avec sa position, et article-pratique).

    L'encart « Aller plus loin » propose un parcours, dans la colonne collante sur un écran large (au-dessus des contenus proches) et sous l'invitation à essayer sur un téléphone : celui de course: (l'identifiant d'un parcours, qui doit exister), sinon le parcours du framework de l'exercice de practice:, comme après un exercice de Pratique. Un parcours en préparation ou non traduit dans la langue de la page n'est pas proposé. Une traduction sans course: reprend celui de l'article français. Les clics se comptent dans Umami (article-parcours, avec le parcours et la position : colonne ou fin).

    Un article d'au moins trois intertitres (##) affiche son sommaire en tête ; chaque intertitre a son ancre. Les blocs de code ont un bouton « Copier ». Sous l'article, l'encart « Écrit par » présente la personne du thème quand elle déclare bio ou links (voir « Thème » plus bas).

    Un bloc de code peut s'ouvrir dans le bac à sable : après son langage, sandbox=php, sandbox=symfony ou sandbox=laravel (le fichier ouvert à l'arrivée du bac à sable), ou sandbox=symfony:src/Controller/MenuController.php pour un autre fichier modifiable. Le bloc devient tout le fichier, il doit donc se suffire (namespace, use…). Dessous, un lien « Essayer ce code dans le bac à sable » emporte le code dans l'adresse (rien n'est stocké) ; content:check refuse un bac à sable ou un fichier qu'il n'accepte pas. Les clics se comptent dans Umami (article-essayer, avec le framework).

    php sandbox=symfony:src/Controller/MenuController.php <?php namespace App\Controller; …

    Un article se traduit en anglais dans <pack>/articles/en/<slug>.md, publié sur /en/blog/<slug> avec sa propre adresse (running-symfony-in-the-browser.md). Même en-tête, plus translation_of:, le nom de l'article français qu'il traduit (une traduction par article). Le blog anglais a sa liste, son flux (/en/blog/rss.xml) et ses entrées du sitemap ; l'interface autour des articles passe en anglais, mais ses liens mènent aux pages françaises. Les deux versions parues se nomment l'une l'autre (hreflang, la française en x-default) et chaque article propose de lire l'autre. Une traduction programmée ou en préparation suit les mêmes règles qu'un article. Un moteur antérieur ignore le dossier en/.

  • moteur: (dans pack.yaml) déclare la ou les versions du moteur avec lesquelles le pack fonctionne, avec la syntaxe de Composer (^0.1, >=1.2 <2.0). Un moteur qui ne la satisfait pas refuse de charger le pack et le dit, plutôt que de le casser en silence. La clé est facultative — un pack livré avec le moteur n'en a pas besoin — mais un pack distribué à part a tout intérêt à la déclarer (voir « Versionnage »).
  • access est dépréciée (sans effet depuis la 0.8.0, signalée par content:check) : le premier chapitre de chaque parcours public est gratuit pour tout compte, la suite dépend de l'accès au parcours (voir « Parcours payants »).
  • editable: accepte des motifs (migrations/*.php ; * ne franchit pas un « / ») pour les fichiers qu'une commande va créer, dont le nom est imprévisible. Tout ce qu'une commande de la console crée, modifie ou supprime sous src/, migrations/, templates/, config/, tests/ ou translations/ (par exemple doctrine:migrations:diff) est reporté dans l'éditeur — un fichier modifiable s'ouvre aussitôt —, dans l'explorateur, dans l'instance de tests et dans le brouillon. « Réinitialiser » retire ces fichiers générés. Quand l'exercice a un motif, le bouton + de l'explorateur crée un fichier qu'il couvre, avec le bon namespace. Les fichiers déjà présents qu'un motif couvre (src/Entity/*.php) ne s'ouvrent pas tous au démarrage : l'apprenant les ouvre depuis l'explorateur. open: doit toujours désigner un fichier, jamais un motif ; ce fichier peut être couvert par un motif s'il existe au départ (content:check le vérifie). Les commandes externes lancées par PHP (proc_open, comme le php-cs-fixer de MakerBundle) se terminent sans rien faire : make:entity, make:controller… fonctionnent dans la console du navigateur.
  • Les tests d'exercices disposent d'outils fournis par l'environnement, dans tests/Formation/ : BaseDeDonnees (recréer la base depuis les entités, enregistrer des objets), FileDeMessages (file Messenger, worker) et MigrationsDeLaBase (vider la base, jouer les migrations jusqu'à une version, comparer le schéma aux entités).
  • setup: liste des commandes bin/console lancées au chargement de l'aperçu, par exemple doctrine:schema:update --force : la base de l'aperçu (SQLite) vit en mémoire et repart de zéro à chaque chargement.
  • requests: propose des requêtes toutes faites dans l'onglet « Requêtes » du playground, un petit client HTTP pour tester une API : {title, method, path, headers, body}. Un body écrit en objet YAML est envoyé en JSON, et {{ date:+7 }} y est remplacé par la date du jour + 7 jours, pour que les exemples ne se périment pas.
  • Les exercices dont l'application crée une base SQLite (var/*.db, database/*.sqlite) ont en plus un onglet « Base » dans le playground : les tables et leurs premières lignes, lues directement dans le fichier. Une valeur chiffrée ou binaire s'y montre en hexadécimal. Rien à déclarer : l'onglet apparaît dès que la base existe.
  • Exercices où l'apprenant écrit ses tests : ses fichiers éditables tests/…Test.php sont ses tests, notés par deux sortes d'objectifs.

    • own-tests: pass : ses tests passent tous sur l'application correcte, avec au moins un test.
    • mutant: <id> : ses tests échouent sur un mutant, une version volontairement cassée de l'application, déclarée dans mutants: [{id, label, changes: [{file, search, replace}]}].

    Chaque mutant est appliqué, testé puis retiré, de la même façon dans le navigateur et dans content:check. content:check vérifie que chaque texte à remplacer existe, et que la solution détecte chaque mutant.

  • docs: liste la documentation qui aide à résoudre l'exercice, {title, url} (URL http(s) uniquement), affichée sous les objectifs. bin/console content:links [pack|parcours|parcours/exercice|pratique|pratique/exercice] vérifie que chaque page répond et que chaque ancre (#…) existe encore (accès réseau requis). Les liens des fiches de cours et les pull_request de la Pratique sont vérifiés en même temps.
  • Fiche de cours : chapters/<chapitre>/lesson.md résume ce qu'un chapitre a enseigné ; l'apprenant y accède une fois le chapitre réussi. Elle commence à ## (le moteur affiche le titre du chapitre en #), utilise des blocs de code avec leur langue (php, twig, yaml…) et des liens https:// ; le HTML brut n'est pas rendu. Structure conseillée : « Ce que vous avez appris », une section par concept avec l'extrait de code clé, « Les pièges », « Pour aller plus loin ». content:check signale (sans échouer) les chapitres qui n'en ont pas. Pour ne pas partir d'une page blanche, bin/console content:lesson-draft <parcours>/<chapitre> écrit un squelette assemblé à partir des exercices du chapitre (concepts, sections « Rappel » des consignes, liens de documentation) ; avec --ia et une clé d'API (voir l'atelier), le modèle rédige un brouillon complet, à relire. --force remplace une fiche existante. La fiche se lit en HTML (/parcours/<parcours>/chapitre/<chapitre>) et se télécharge en PDF, établi au nom de l'apprenant (dompdf, en PHP pur : aucune dépendance système). Une fois le parcours terminé, /parcours/<parcours>/livret.pdf regroupe toutes ses fiches derrière une page de garde et un sommaire.
  • Environnements : environments/<id>/environment.yaml décrit un projet de base (id, title, php), empaqueté par environments/bin/build-env.sh [<id>] (sans argument : tous) en archive zip servie au navigateur, avec un index de complétion généré par Reflection. Tout ce qui concerne les environnements vit dans ce dossier — les projets et leur empaquetage —, prêt à partir dans son propre dépôt : il ne dépend d'aucun code du moteur, et le moteur n'y entre que par ENVIRONMENTS_DIR et ce script (voir environments/README.md). framework: choisit le runtime (symfony par défaut, laravel, docker, nuxt) : sa console, l'organisation de son projet, ses caches, son lanceur de tests. framework: docker bascule sur le simulateur Docker (voir plus bas) ; framework: laravel sur les conventions de Laravel : console artisan, dossiers (app/, routes/, resources/…), vues Blade compilées dans storage/framework/views vidées entre deux runs de tests (cache: pour un autre dossier). extends: <id> prolonge un autre environnement : le projet joué est la superposition de la chaîne, du plus général au plus particulier, et l'environnement ne porte que ce qu'il ajoute ou remplace (symfony-8-app est passé de 45 à 26 fichiers, symfony-8-doctrine de 33 à 14). Les clés d'environment.yaml s'héritent sauf title ; vendor/ ne se superpose pas — il vient du seul dossier de la chaîne qui déclare un composer.json, si bien qu'un environnement sans dépendance propre n'a ni composer.json ni composer.lock à porter. Détails et règles dans environments/README.md. Un fichier .archiveignore (motifs zip -x, un par ligne) allège l'archive, par exemple des traductions de vendor/ inutiles à l'apprenant. Les environnements Laravel forcent APP_ENV=testing et APP_RUNNING_IN_CONSOLE=true dans phpunit.xml : Laravel n'exempte les tests de la vérification CSRF que si les deux moitiés de runningInConsole() && runningUnitTests() sont vraies, et la première se déduit de PHP_SAPI, qui vaut wasm dans le navigateur (ni cli ni phpdbg) — sans cela, tout POST d'un test de fonctionnalité répond 419. L'aperçu, lui, vérifie le jeton pour de vrai. Leur .env est livré tel quel : rien n'y est secret, le projet ne quitte jamais le bac à sable.
  • Runtimes : tout ce que le moteur sait d'un framework est déclaré une fois, dans un profil (platform/src/Content/Framework/Profiles/) : nom, console et commande d'exemple, dossiers du projet et du code, caches à vider, dossiers masqués, racines de namespace, lanceur de tests (PHPUnit ou Vitest), paquet dont la version fait foi pour version:, langages des fiches de cours et conventions données au modèle qui rédige. Le navigateur le reçoit dans la charge utile de l'exercice au lieu de le réécrire : l'éditeur ne contient plus de table de frameworks, seulement le code propre à chaque runtime (le worker, les snippets, le script de la console).

    Ajouter un framework : fournir un FrameworkProfileProvider, une classe découverte par son étiquette de service. Rien d'autre, tant qu'il s'exécute avec un runtime existant — Laravel et le simulateur Docker tournent tous deux sur php-wasm, comme Symfony.

    Ajouter un runtime — une autre façon d'exécuter un projet dans le navigateur — se fait en installant un paquet, sans modifier un fichier du moteur. Le paquet déclare dans son package.json :

    { "forelse": { "runtime": "./src/browser/forelse.ts", "vite": "./src/node/vite.ts" } }

    runtime exporte le manifeste (identifiant, libellé, fabrique) ; vite, facultatif, ce que le runtime demande au build — greffons, constantes, alias. Le playground parcourt ses dépendances au build, trouve les paquets qui portent ce champ et les enregistre seuls (discover-runtimes.ts). Aucune liste à tenir à jour. Côté serveur, le profil du framework déclare runtime: 'mon-runtime' et arrive par l'étiquette de service app.framework.

    Le simulateur Nuxt est le premier à passer par ce chemin : @forelse/simulateur-nuxt porte son runtime, son worker, ses greffons Vite et son lanceur de tests, et playground/vite.config.ts ne nomme plus Nuxt nulle part. Les types que les deux côtés partagent vivent dans @forelse/runtime-contract.

    Les tests côté serveur suivent la même règle. content:check doit rejouer les tests d'un exercice avec le même runner que le navigateur, sinon les deux verdicts divergent. Un framework dont les tests ne sont pas du PHPUnit déclare donc testModule dans son profil — un spécificateur de paquet, jamais un chemin :

    testModule: '@forelse/simulateur-nuxt/tests',

    Le moteur demande à Node de le résoudre depuis playground/, là où les paquets de runtime sont installés : il ne sait ni où ce paquet vit, ni qu'il existe. Le module reçoit le dossier du projet puis les fichiers de test, et écrit sur la sortie standard un JSON {cases: [{name, status, file, message}], output}.

    Ce qui ne changera pas : le navigateur est un bundle, donc ajouter un runtime demandera toujours de reconstruire le playground. C'est le niveau 3 de ANALYSE-MARQUE-BLANCHE.md. Ce qui a changé, c'est qu'il n'y a plus rien à y modifier.

    Le profil déclare aussi ce que le navigateur devinait auparavant : snippets (les familles d'extraits que l'éditeur propose) et consoleAliases (les préfixes tolérés — php bin/console …, docker-compose up). L'éditeur et la console ne connaissent plus aucun framework par son nom.

  • Simulateur Docker (framework: docker, environnement environments/docker) : Docker ne tourne évidemment pas dans le navigateur. packages/simulateur-docker le simule en PHP pur — images (catalogue fermé : php, composer, nginx, postgres, mysql, mariadb, redis, node, alpine, debian, caddy, mailpit, adminer…), construction d'images à la façon de BuildKit (étapes numérotées, cache par couche, « build checks »), conteneurs, réseaux, volumes, docker compose — et exécute pour de vrai le PHP servi par les conteneurs : Apache et son .htaccess, nginx devant php-fpm, php -S. Les erreurs sont celles de Docker (port is already allocated, Unable to locate package, host not found in upstream, File not found.), et une suppression de fichiers dans une couche ultérieure ne rend pas la place, comme dans une vraie image. La console du playground devient docker (sh lancer.sh rejoue un script de commandes), et l'aperçu visite les ports publiés : preview: /localhost:8080/. Les tests des exercices étendent Forelse\DockerSim\Testing\DockerTestCase (build(), docker(), runScript(), http(), container(), service(), image(), exec(), et les assertions assertBuildSucceeded, assertImageLacksFile, assertImageSizeBelow, assertPageContains…). Le simulateur est un runtime du moteur, pas un environnement : il reste dans tools/, et environments/docker en dépend comme d'une bibliothèque (dépôt Composer path). Il a sa propre suite de tests (cd packages/simulateur-docker && vendor/bin/phpunit), lancée par make test et la CI. Limites assumées : pas de registre (docker push), pas de terminal interactif (-it), RUN interprété et non exécuté, montages imbriqués non pris en charge, temps comprimé (un healthcheck est rejoué à chaque fois qu'on regarde l'état d'un conteneur, sans phase starting, et un échec y vaut tous les essais) ; le PHP des conteneurs s'exécute dans le processus des tests (pas d'exit(), pas de fonction globale redéclarée), et n'est donc pas soumis aux droits Unix — ceux-ci s'appliquent aux commandes du shell (docker exec -u www-data … touch, un montage :ro), qui sont le bon moyen de les vérifier.
  • visibility: admin (dans track.yaml) réserve un parcours en préparation aux administrateurs : absent de l'accueil, introuvable (404) pour les autres, jamais conseillé comme suite. Il reste vérifié par content:check et modifiable dans l'atelier. Retirez la clé (ou visibility: public) pour l'ouvrir. Un administrateur peut aussi l'ouvrir à une seule cohorte en le cochant dans ses parcours disponibles.
  • order: <entier> (dans track.yaml) fixe le rang du parcours dans les listes, à commencer par l'accueil, dont l'onglet ouvert par défaut est le premier : le plus petit d'abord. Les parcours sans rang viennent après, dans l'ordre de chargement (chemins de CONTENT_PACKS_PATHS, puis dossiers de packs par ordre alphabétique, puis liste tracks: du pack). Laissez de l'écart entre les rangs (10, 20, 30) pour intercaler un parcours d'un autre pack sans renuméroter.
  • downloads: [<fichier>, …] (dans track.yaml) réserve des fichiers de DOWNLOADS_DIR (servis par /telechargements/<fichier>) à ceux qui ont accès à tout le parcours : un compte qui ne l'a pas reçoit une 403, et le fichier d'un parcours en préparation n'existe pas pour les autres (404). Un fichier qu'aucun parcours ne déclare reste ouvert à tout compte connecté. Un moteur antérieur à la 2.1 ignore la clé, et sert alors le fichier à tout compte connecté.
  • image: <fichier> (dans track.yaml) donne au parcours son image de partage : un PNG, JPEG ou WebP du dossier du parcours, d'au moins 1200 × 630 et au format 1,91:1 des aperçus de liens (entre 1,8:1 et 2:1). Elle s'affiche quand on partage la page du parcours, ses sommaires de chapitre ou ses exercices (LinkedIn, Slack, X…), à la place de l'image de la marque. Le chargement du pack, et donc content:check, refuse une image absente, hors du dossier ou mal dimensionnée. Un moteur antérieur à la 2.4 ignore la clé.
  • former_ids: [<ancien id>, …] (dans track.yaml, ou dans l'exercise.yaml d'un exercice de parcours ou de Pratique) garde les adresses d'un parcours ou d'un exercice renommé : une page introuvable sous un ancien identifiant redirige (301) vers la même page sous l'identifiant actuel, requête comprise. Liens partagés, favoris et moteurs de recherche suivent. Le chargement du pack refuse un ancien identifiant qui est celui d'un autre parcours (ou exercice du même parcours) actuel, ou que deux déclarent. La progression enregistrée sous l'ancien identifiant, elle, n'est pas reprise. Un moteur antérieur à la 2.4 ignore la clé.
  • level: beginner|intermediate|advanced (dans track.yaml) donne le niveau visé par le parcours. Il est repris en educationalLevel (« Débutant », « Intermédiaire », « Avancé ») dans les données structurées du parcours et de ses exercices. Un moteur antérieur à la 2.4 ignore la clé.
  • description: <texte> (sur un chapitre de track.yaml) dit en une ou deux phrases ce que le chapitre fait apprendre, sans HTML. Elle s'affiche sous le titre du chapitre, sur la carte du parcours (même replié) comme dans son sommaire, dont elle devient la meta description à la place du texte généré à partir des notions ; les données structurées du cours la reprennent (syllabusSections). Un moteur antérieur à la 2.4 l'ignore, un moteur antérieur à la 3.23 ne l'affiche pas sur la carte du parcours.
  • about.md (à côté de track.yaml, facultatif) présente le parcours en Markdown : ce qu'on y apprend, pour qui, avec quels prérequis. Il s'affiche sous la liste des chapitres de la page du parcours, et donne aux moteurs de recherche un texte où les titres d'histoire des chapitres ne disent pas le sujet. Commencez par un titre ## : la page a déjà son h1. Un parcours traduit attend sa traduction dans i18n/<langue>/about.md. Un moteur antérieur à la 3.23 l'ignore.
  • next: <parcours> (dans track.yaml) conseille un parcours à suivre ensuite : proposé à la fin du dernier exercice et sur la carte du parcours. Il peut vivre dans un autre pack ; s'il n'est pas installé, il est ignoré.
  • environment: choisit l'environnement d'exécution, au niveau du parcours, d'un chapitre ou d'un exercice (symfony-8, symfony-8-doctrine, symfony-8-app…). symfony-8-2-dev suit Symfony 8.2 en développement (8.2.x-dev, figé par son composer.lock), avec la console, PropertyAccess, Serializer et Validator : il sert aux exercices de Pratique sur les nouveautés annoncées avant la sortie, et disparaîtra quand symfony-8 passera en 8.2. symfony-8-2-doctrine-dev le prolonge avec Doctrine ORM (SQLite) et le composant KeyManagement (colonnes chiffrées, index aveugles), sur un KMS local OpenSSL dont la clé, publique, est dans son .env. symfony-8-easyadmin (Symfony 8.1, Doctrine, EasyAdmin 5) sert aux exercices sur EasyAdmin ; il déclare extensions: [intl], si bien que le PHP du navigateur charge intl (≈ 13,5 Mo de plus au premier passage) pour lui seul.
  • base: <exercice> fait partir un exercice de l'état final d'un exercice précédent : un fil rouge s'enchaîne sans dupliquer de fichiers.
  • bin/console content:check [pack|parcours|parcours/exercice|pratique|pratique/exercice] vérifie chaque exercice : tests rouges sur l'état de départ, verts avec la solution. --keep conserve le projet reconstitué pour l'inspecter. PHPUnit y tourne avec memory_limit=512M et sans Xdebug, et --chapitre <id> limite la vérification à un chapitre (pratique pour paralléliser).

⚠️ content:check exécute le PHP des packs sur votre machine : ne l'utilisez qu'avec des packs de confiance.

Écarts entre PHP natif et navigateur. content:check s'exécute avec le PHP de la machine, alors que l'apprenant utilise php-wasm, qui n'a pas l'extension intl : la locale y reste en. Évitez donc ce qui dépend d'intl (NumberType sans 'html5' => true, MoneyType, dates localisées…), sous peine de voir des exercices validés par content:check se comporter autrement dans le navigateur. La traduction (messages de validation en français) n'est pas concernée.

Thèmes

Un thème habille une instance : son nom, ses couleurs, ses polices, ses images, ses textes d'accueil, et au besoin ses propres gabarits, feuilles de style et scripts. C'est un dossier monté à côté des packs : aucun fork, aucune image à reconstruire, rien à republier au titre de l'AGPL — c'est de la configuration, pas du code du moteur.

Sans thème, l'instance prend le thème default du moteur : sobre, en Tailwind, polices du système, clair ou sombre selon le système, et qui s'appelle « default ». Il est toujours disponible, même quand d'autres thèmes sont installés.

Installer des thèmes

THEMES_DIR (/themes dans l'image, le volume ./themes des compose.yaml) contient un sous-dossier par thème, nommé par son dossier (minuscules, chiffres, tirets) : ce nom est l'identifiant du thème. Le volume est en lecture seule.

themes/mon-theme/
  theme.yaml      nom, puce, accroche, site, couleurs, polices, images, textes de l'accueil, fichiers à charger
  logo.svg  favicon.svg  partage.png      les images, nommées dans theme.yaml
  templates/      (facultatif) des gabarits Twig qui remplacent ceux du moteur
  assets/         (facultatif) feuilles de style, scripts, polices et images, servis tels quels

Un exemple complet et commenté, à copier : examples/themes/atelier-bigorneau/ — un theme.yaml, une petite feuille, un court script et une surcharge du bandeau d'accueil. make dev le présente dans l'admin, prêt à être prévisualisé. bin/console app:theme:verifier lit le thème actif en entier et dit quelle clé corriger.

Choisir le thème actif
  • /admin → Thèmes : l'état de chaque thème (theme.yaml valable, gabarits qui se compilent), un aperçu qui ne montre le thème qu'à vous, sur toutes les pages, puis Activer, qui le montre à tous dès la requête suivante, sans redémarrer. Un thème en erreur ne s'active pas.
  • En ligne de commande, pour un déploiement scripté : bin/console app:theme:activer <thème>.
  • La variable THEME donne le thème de départ, tant que l'admin n'en a pas choisi un. Sans elle, c'est default.

Pas de téléversement depuis l'admin, et c'est voulu : les gabarits et les scripts d'un thème s'exécutent avec les droits de la plateforme. Un thème arrive par le dossier monté, par qui administre le serveur.

L'ancien dossier unique (BRANDING_DIR, /marque) reste lu jusqu'à la 4.0 : il apparaît comme le thème instance, actif tant que l'admin n'en a pas choisi un autre. Avant la 2.6, theme.yaml s'appelait marque.yaml et les gabarits lisaient la variable marque : les deux restent acceptés jusqu'à la 4.0, avec un avertissement (voir le journal, « Migration »).

Le format d'un thème

La règle à retenir : dès que theme.yaml existe, rien du thème default ne subsiste — ni son nom, ni son accroche, ni sa favicon. Un thème déclare les siens ; ce qu'il ne déclare pas n'apparaît pas.

  • name (obligatoire) s'affiche dans l'en-tête, le pied de page, les <title> (« Mon compte · … »), les emails (confirmation d'adresse, mot de passe oublié, achat), les balises Open Graph et les données structurées. chip est la petite puce à côté, tagline l'accroche, title le <title> de l'accueil, url le site de la marque, person (name, jobTitle) l'auteur des parcours et des articles dans les données structurées. Avec bio (deux ou trois phrases) ou links (malt, linkedin, github, site, en https), un encart « Écrit par » suit chaque article ; portrait (une image carrée de assets/) y met une photo, about (du Markdown) remplit sa page /auteurs/<nom>, et locales.<langue>.person traduit jobTitle, bio et about. D'autres auteurs se déclarent sous authors:, par identifiant (grace-hopper: {name, jobTitle, bio…}, même forme, traduits sous locales.<langue>.authors) ; un article nomme le sien avec author: grace-hopper, sinon c'est person. Chaque auteur qui a une présentation a sa page (/auteurs/grace-hopper, /en/authors/…) : photo, présentation, profils, ses articles, et une ProfilePage dans les données structurées. Pour en faire une vitrine, person accepte aussi location (la ville, à côté du métier), availability (une pastille, « Disponible pour une mission »), booking (un lien de rendez-vous en https), cta (le titre du dernier appel), expertise et skills (des listes d'étiquettes, aussi knowsAbout des données structurées), et des listes de fiches : stats (value, label), services (title, text), press (name, text, url) et testimonials (quote, author). Toutes se traduisent sous locales.<langue>.person, sauf booking.
  • colors et fonts redéfinissent les jetons du thème default (--color-accent, --color-bg, --font-serif…, voir docs/themes.md) : les pages du site et les emails les suivent. Hexadécimal seulement ; une valeur mal écrite arrête la page avec un message qui dit laquelle — une instance à moitié habillée est pire qu'une erreur. Sans colors, les pages suivent la préférence claire ou sombre du système ; dès que colors est déclaré, elles restent claires, dans la palette du thème.
  • editor habille l'éditeur d'exercice et l'atelier, toujours sombres. Les deux palettes se déclarent séparément et l'une n'est jamais déduite de l'autre : une couleur claire assombrie automatiquement, c'est un contraste perdu au hasard. Sans editor, l'éditeur garde la palette neutre du moteur.
  • logo, icon et share nomment des fichiers de ce dossier (jamais un chemin), servis sur /theme/<thème>/<date>/<rôle> : une image remplacée change d'URL. Sans icon, l'onglet n'affiche aucune icône. email_logo (facultatif) est le logo des emails, en PNG, JPEG ou GIF, quand celui du site est dans un format que les clients mail lisent mal (webp, svg).
  • share_card habille les images de partage que le moteur dessine pour chaque exercice de la Pratique (le titre de l'exercice, son framework et le site, en 1200 × 630, sur /pratique/<exercice>/image-de-partage) et pour chaque article du blog (/blog/<slug>/image-de-partage). À droite, un éditeur de code stylisé prend les couleurs de l'éditeur du thème (editor, ci-dessus) ; en pied, le logo des emails (email_logo, ou logo en PNG, JPEG ou GIF) et le nom du thème. Tout y est facultatif : image (un fond PNG ou JPEG de ce dossier, mis à l'échelle et rogné, qui remplace l'éditeur stylisé ; laissez de la place à gauche, où s'écrit le texte), font et text_font (TrueType ou OpenType, pas de woff2 : le titre, puis le reste), background, ink, accent, muted (couleurs hexadécimales). Sans eux : les couleurs du site (colors), sinon celles des emails, et une police du moteur (DejaVu Sans). L'image share reste celle des pages qui n'ont pas la leur.
    share_card:
      image: fond-partage.png
      font: polices/titres-bold.ttf
      accent: '#9a3412'
  • home.showcase, home.author et home.demo remplissent les sections de l'accueil qui parlent de la marque (le fil rouge, « qui est derrière », l'illustration du bandeau). Une section non déclarée n'apparaît pas. Piège du YAML : une phrase qui contient « : » doit être entre guillemets, sinon elle devient un tableau — le message le dit plutôt que de laisser la page échouer à l'affichage.
  • stylesheets, scripts et preload listent des fichiers de assets/ (.css, .js, .woff2), posés après ceux du moteur sur les pages du site et servis sur /theme/<thème>/<version>/assets/…, en cache immuable. Les scripts ajoutent au JavaScript du moteur (animations, composants d'accueil, en respectant prefers-reduced-motion). replaces_engine_styles: true retire la feuille du moteur : celle du thème habille seule les pages.
    stylesheets: [assets/theme.css]
    scripts: [assets/theme.js]
    preload: [assets/fonts/titres.woff2]
    replaces_engine_styles: false
  • templates/ : un fichier y remplace le gabarit de même nom du moteur. Les points de surcharge — lesquels se remplacent, avec quelles variables, ce qui y est obligatoire — sont décrits dans docs/themes.md, avec la façon de construire sa feuille (Tailwind ou non). Un gabarit redéposé se voit dès la requête suivante.

Ce qu'un thème ne touche pas : la page d'exercice et les éditeurs de l'atelier. Ils gardent l'habillage du moteur et ne chargent ni les gabarits, ni les feuilles, ni les scripts du thème ; le thème les habille seulement par sa palette editor, sa police fonts.mono, son nom et son logo.

Ce qui reste du moteur dans tous les cas : le pied de page dit la version et la licence AGPL du moteur, avec le lien vers son code, comme l'exige la licence.

Les tarifs de cohorte (COHORT_UNIT_PRICE, COHORT_TIERS) et les mentions légales (LEGAL_*) ne sont pas dans le thème : ce sont des variables d'environnement.

Ajouter un environnement depuis un dépôt Git

Un environnement d'exécution n'a pas à vivre dans ce dépôt. /admin → Environnements en installe un depuis une adresse https:// : le moteur clone, vérifie, puis empaquette (archive du projet, index de complétion) sans reconstruire l'image. C'est le pendant, pour les runtimes, de ce que CONTENT_PACKS_PATHS fait pour le contenu et BRANDING_DIR pour l'habillage.

⚠️ Empaqueter exécute le code du dépôt sur ce serveur (composer install, ses scripts et ses greffons). Même confiance que pour un pack passé à content:check. La page est réservée aux administrateurs, ENVIRONMENT_SOURCES_ALLOWLIST restreint les hôtes acceptés, et une instance qui ne veut pas de la fonctionnalité laisse INSTALLED_ENVIRONMENTS_DIR vide : la page reste, sans formulaire.

  • Le dépôt se nomme lui-même : l'identifiant vient de l'id: de son environment.yaml, pas de l'adresse ni de l'administrateur. Un identifiant déjà porté par un environnement du moteur est refusé — un environnement installé n'en masque jamais un autre.
  • extends: symfony-8 fonctionne d'un dépôt à l'autre : un environnement installé peut prolonger un environnement du moteur, et n'a alors ni composer.json ni vendor/ à porter.
  • ENVIRONMENTS_DIR accepte plusieurs dossiers séparés par des virgules (ceux du moteur, puis ceux de l'instance) ; INSTALLED_ENVIRONMENTS_DIR désigne celui, écrivable, où l'administration installe.
  • Les archives d'un environnement installé ne sont pas dans public/ : elles sont servies par /envs/<id>.zip depuis le dossier des installations. Les archives du moteur restent des fichiers statiques sur la même URL.
  • Garde-fous : https:// seulement (ni file://, ni git@, ni git://), clone superficiel, minuterie, plafond de taille avant toute exécution, et jamais de shell — les commandes sont des tableaux d'arguments.

En ligne de commande : bin/console app:environnement:installer <adresse> [--ref=<branche>], ou le même avec l'identifiant d'un environnement déjà installé pour le mettre à jour. C'est cette commande que la page lance en tâche de fond, parce qu'un composer install dure plus longtemps qu'une requête HTTP.

Guide pas à pas dans auto-hebergement/README.md.

Un pack apporte ses environnements

Le cas ordinaire : le décor vit dans le pack, à côté des parcours qu'il sert.

mon-pack/
  pack.yaml
  tracks/…
  environments/ma-boutique/environment.yaml    ← l'environnement, porté par le pack
  environments/ma-boutique/src/…

Rien à déclarer, rien à installer : le moteur le trouve du seul fait que le pack est monté. Un décor appartient au contenu qui le met en scène — il se déplace avec lui, se versionne avec lui, et disparaît avec lui. extends: symfony-8 fonctionne depuis un pack comme depuis ailleurs, ce qui rend « le Symfony complet, plus mes trois entités » tenable en une poignée de fichiers.

Il reste à l'empaqueter — composer install, archive, index de complétion —, ce que fait bin/console app:environnement:synchroniser (ou le bouton de /admin → Environnements, ou ENVIRONMENTS_AUTO_INSTALL=1 au démarrage). Les archives vont dans INSTALLED_ENVIRONMENTS_DIR, hors de l'arborescence publique. Une nouvelle version du pack qui modifie le décor, ou le socle qu'il prolonge, le fait réempaqueter à la synchronisation suivante : une empreinte du contenu des sources (hors vendor/) est notée à côté de l'archive (<id>.sources).

Un pack ne peut pas s'approprier un identifiant déjà pris : symfony-8 déclaré deux fois arrête le chargement en nommant les deux dossiers, plutôt que d'en masquer un au hasard.

Ce que le moteur ne fait jamais :

  • empaqueter pendant une requête web. composer install dure des minutes et exécute le code du pack : cela n'arrive que sur un geste d'exploitation — la commande, le bouton, ou le démarrage si l'instance l'a demandé. La visite d'un apprenant ne déclenche rien.
  • refaire ce qui est déjà fait. Un environnement dont l'archive existe est laissé tel quel.
  • laisser un pack s'approprier un identifiant. symfony-8 déclaré deux fois arrête le chargement en nommant les deux dossiers, plutôt que d'en masquer un au hasard.
  • livrer une archive amputée. Un extends: qui pointe sur une base absente arrête l'empaquetage ; et un décor qui ne compile pas n'empêche pas les autres d'être empaquetés.

Un décor trop gros pour vivre dans un pack, ou partagé entre plusieurs, s'installe à part depuis un dépôt Git — voir la section précédente ; c'est alors un geste d'administrateur, pas une déclaration du pack.

Pages publiques et référencement

Rien n'est indexable par défaut : App\Seo\SearchIndexing tient la liste blanche des routes qu'un visiteur sans compte peut lire, et toute autre page sort en noindex (de même que le bac à sable, et qu'une instance en SEARCH_INDEXING=0). robots.txt et sitemap.xml sont servis par l'application, car ils dépendent de l'origine et des packs installés.

Les pages publiques forment une hiérarchie, de l'accueil à l'exercice :

Accueil ── /parcours/<parcours> ── /parcours/<parcours>/chapitre/<chapitre>/sommaire ── /parcours/<parcours>/<exercice>
                                                    │
                          /notions ── /notions/<notion> ─┘   (transversal : relie les exercices d'une même notion)

Accueil ── /pratique ── /pratique/<exercice>
              └─ /pratique/nouveautes/<framework>-<version>      (transversal : les exercices d'une même version)
  • Sommaire de chapitre (ChapterOutline) : public et indexable. Il annonce ce que le chapitre fait apprendre et mène à chacun de ses exercices. Rien de ce qui résout un exercice n'y figure — pas même le texte des objectifs, que des tests vérifient. La fiche de cours du chapitre (/parcours/<parcours>/chapitre/<chapitre>), elle, reste réservée à qui a réussi le chapitre.
  • Notions (ConceptIndex) : les concepts: déclarés par les exercices deviennent des pages, qui rapprochent un exercice de parcours d'un exercice de Pratique. Une notion vue sur un seul exercice ne relie rien : elle reste une pastille, sans page à elle (notion_url() ne lui donne pas de lien). Une page de notion sans texte n'est qu'une liste de liens, que les moteurs de recherche jugent mince : elle se suit, mais ne s'indexe pas (noindex, follow) et reste hors du sitemap, tant que le pack ne lui écrit pas d'intro (<pack>/notions/controleur.md, du Markdown nommé comme l'adresse de la notion, avec un en-tête YAML facultatif dont la seule clé, title, donne le nom de la page tel qu'on le cherche). L'intro ouvre alors la page et fait sa description ; content:check signale une intro que rien n'affiche. L'index /notions a un onglet par framework, seulement pour ceux qui ont au moins une notion à ouvrir. Un pack peut y ranger les notions par thème (<pack>/notions/themes.yaml : sous chaque framework, une liste de thèmes avec title, description facultative et notions, par leur libellé dans concepts:) ; une notion rangée sans page encore s'annonce « bientôt », une notion qui a une page sans être rangée tombe dans « Autres notions ». La notion la plus pratiquée du framework (au moins trois exercices) a son encart, « Le fil rouge ». Un seul pack range les notions d'un framework. Les onglets et le filtre par thème viennent avec JavaScript ; sans, tout s'affiche. En anglais, /en/concepts rassemble les notions du seul contenu traduit (exercices de Pratique traduits, parcours entièrement traduits), sous les libellés de la traduction (concepts de i18n/en/exercise.yaml, qui gardent l'ordre des notions françaises) : « Controller » a sa page /en/concepts/controller, son intro dans <pack>/notions/en/controller.md (même format), et l'index anglais se range par ses propres thèmes, <pack>/notions/en/themes.yaml (même format, libellés anglais). Une notion anglaise et une notion française s'annoncent l'une l'autre (hreflang) quand la première occupe, dans chacun de ses exercices, la place de la seconde dans l'exercice français.
  • Nouveautés d'une version (PracticeVersionIndex) : le version: déclaré par les exercices de Pratique donne une page par version — « les nouveautés de Symfony 8.2 » est ce qu'un développeur cherche, et il tombe sur des exercices à faire plutôt que sur un billet à lire. Rien n'y est rédigé : la page se remplit quand un exercice paraît, et elle annonce qu'elle ne liste pas toutes les nouveautés de la version, seulement celles qui ont un exercice. Une version qu'un seul exercice pratique n'a pas de page (comme une notion vue une seule fois), et un exercice programmé n'y entre pas avant sa parution. Le texte de la page se compose de ses notions (« 6 exercices courts, autour de Form, Console et Validator ») tant que personne n'en a écrit un : deux pages ne se ressemblent donc jamais, sans rien rédiger. Une intro écrite dans le pack (<pack>/versions/symfony-8-2.md, du Markdown sans titre) la remplace, en haut de page comme en description : l'auteur dit ce que la version apporte et répond de ce qu'il promet — la page retire alors sa réserve sur l'exhaustivité. Le nom du fichier est l'identifiant de la version dans l'adresse ; content:check signale une intro que rien n'affiche. Le groupement dépend du framework, que son profil déclare (versionParts) : une nouveauté Symfony appartient à une mineure qu'on attend (« Symfony 8.2 »), une nouveauté Laravel sort dans une mineure parmi vingt et se range sous la majeure (« Laravel 13 »). La liste /pratique mène à ces pages, et la pastille de version d'un exercice mène à la sienne.
  • Le title d'un exercice et d'un sommaire est mené par ses notions, puis fermé par son libellé narratif : c'est « Boucle Twig » que l'on cherche dans un moteur, pas « Les prix en pièces d'or » — mais seul ce libellé distingue deux pages d'une même notion.
  • La page publique d'un exercice mène aux autres exercices de son chapitre : sans cela, tous les exercices d'un parcours ne pendent que de sa page, sans se relier entre eux.
  • lastmod vient de la date de modification des fichiers du pack. Elle n'a de sens que si l'installation des packs la conserve : git n'en garde aucune, et le déploiement des packs les rétablit depuis le journal avant de copier (voir le workflow du dépôt de contenu).
  • PublicPagesSeoTest parcourt toutes les adresses du sitemap et vérifie leurs balises : title unique et dans la limite, description, canonical absolu, Open Graph, un seul h1. Une page publique ajoutée sans ses balises y échoue.

Sécurité

  • Aperçu isolé : le HTML/JS produit par l'apprenant s'affiche sur une origine distincte (SANDBOX_ORIGIN), sans cookie ni accès à la plateforme. Chaque origine ne sert que ses propres pages (OriginIsolationListener).
  • Workers des runtimes : le PHP WebAssembly et le simulateur Nuxt (qui évalue les routes server/ de l'apprenant) tournent dans des workers de l'origine de la plateforme. Leur script est servi avec Content-Security-Policy: connect-src <APP_URL>/envs/ <APP_URL>/build/ (docker/Caddyfile) : ils téléchargent leurs archives, mais un fetch('/api/…') ne part pas avec la session. En développement, les workers viennent du serveur de Vite, sans cette politique.
  • Cookies de session limités à l'hôte de la plateforme (HttpOnly, SameSite=Lax, pas de cookie_domain).
  • Écritures refusées si l'en-tête Origin diffère de celle de la plateforme ; consignes Markdown nettoyées (DOMPurify).
  • La réussite d'un exercice est constatée dans le navigateur : l'XP est donc falsifiable. C'est acceptable pour apprendre, mais il ne faut pas s'en servir pour certifier.

Auto-héberger avec Docker

L'image publiée contient le moteur prêt à servir (FrankenPHP/Caddy, utilisateur non-root) ; PostgreSQL tourne à côté, et les packs de contenu restent hors de l'image, montés dans /packs. En bref :

mkdir apprendre && cd apprendre
curl -fsSLO https://raw.githubusercontent.com/oipnet/apprendre-forelse/main/auto-hebergement/compose.yaml
curl -fsSL -o .env https://raw.githubusercontent.com/oipnet/apprendre-forelse/main/auto-hebergement/.env.example
# renseigner .env : APP_URL, SANDBOX_URL (deux domaines distincts), POSTGRES_PASSWORD, emails, mentions légales
docker compose up -d --wait

Le guide complet est dans auto-hebergement/README.md : prérequis, premier administrateur, packs, reverse proxy, essai en local, mises à jour et choix de l'étiquette d'image, sauvegardes, dépannage.

Pour essayer l'image construite depuis ce dépôt, avec le pack de démonstration : docker compose up --build à la racine (plateforme http://localhost:8080, bac à sable http://127.0.0.1:8080).

Sans Docker : make build, puis servez platform/public sur les deux origines (DEFAULT_URI et SANDBOX_ORIGIN) avec APP_ENV=prod et date.timezone réglé dans php.ini. Les archives de environments/bin/build-env.sh doivent être dans platform/public/envs.

Versionnage

Le moteur suit le versionnage sémantique (MAJEUR.MINEUR.CORRECTIF), et les changements sont consignés dans CHANGELOG.md. La version vit dans le fichier VERSION ; elle s'affiche en pied de page et au début de content:check.

Ce qu'un numéro promet porte sur ce que le moteur expose au dehors :

  1. Le format de pack — pack.yaml, track.yaml, exercise.yaml, les outils de test fournis aux exercices (tests/Formation/, DockerTestCase) et les identifiants d'environnements.
  2. La configuration d'une instance — variables d'environnement, compose.yaml, volumes, base de données.
  3. Les URL publiques de la plateforme (/parcours/…, /pratique/……).

Donc :

  • MAJEUR : un pack qui fonctionnait ne fonctionne plus, ou une instance qui tourne casse sans intervention (variable renommée, migration manuelle à faire).
  • MINEUR : nouvelle clé du format, nouvel environnement, nouvelle fonctionnalité — les packs existants continuent de fonctionner.
  • CORRECTIF : corrections seules.

Depuis la 1.0.0, le format de pack est tenu pour stable : seule une majeure peut casser un pack ou une instance. (En 0.x, une mineure le pouvait.)

Le code PHP et TypeScript n'est pas une API publique : les classes, services et modules sont réorganisés librement d'une version à l'autre. Ce qui est tenu, c'est le format de pack et la configuration, pas les signatures internes.

Dépréciation : une clé du format n'est jamais retirée du jour au lendemain. Elle est d'abord signalée par content:check dans au moins une version mineure, puis retirée dans une majeure.

Publier une version (mainteneurs) : décrire les changements sous « Non publié » dans le journal, puis

tools/release.sh 1.1.0
git push --follow-tags

Le script vérifie le dépôt, met à jour VERSION, playground/package.json et le journal, commite et étiquette. L'étiquette poussée déclenche la publication des images Docker correspondantes.

Licence

AGPL-3.0-or-later. Le runtime s'appuie sur php-wasm (GPL-2.0-or-later).

Frequently asked questions

What is PHP, Symfony & Laravel playground?

Create shareable links to run Symfony, Laravel or plain PHP code in the browser, no install.

How do I install PHP, Symfony & Laravel playground?

It runs as a remote server. In Claude Code: claude mcp add --transport http php-symfony-laravel-playground 'https://apprendre.forelse.fr/mcp'. For other clients, paste the JSON configuration from this page into their MCP settings.

Does PHP, Symfony & Laravel playground need an API key?

Its registry entry lists no required key or secret. Some features may still need an account with the service it connects to.

Is PHP, Symfony & Laravel playground open source?

Yes. Its code is on GitHub under the AGPL-3.0 license.

Who maintains PHP, Symfony & Laravel playground?

It is published by Oipnet. AI.tn lists it but does not maintain or endorse it.

From the official MCP Registry. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.