PHP, Symfony & Laravel playground
Create shareable links to run Symfony, Laravel or plain PHP code in the browser, no install.
Installation
Ajoutez ceci à la configuration MCP de votre client (Claude Desktop : claude_desktop_config.json ; Cursor : .cursor/mcp.json ; VS Code et les autres utilisent la même forme « mcpServers »).
claude mcp add --transport http php-symfony-laravel-playground 'https://apprendre.forelse.fr/mcp'
{
"mcpServers": {
"php-symfony-laravel-playground": {
"type": "http",
"url": "https://apprendre.forelse.fr/mcp"
}
}
}
Documentation
Tirée du README du projet sur GitHub, actualisée chaque semaine. Les liens ouvrent GitHub.
Sommaire
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).
- Installer une instance avec Docker : auto-hebergement/README.md
- Écrire des exercices : Packs de contenu et L'atelier des auteurs
- Développer le moteur : Démarrer en local
- Ce qui change d'une version à l'autre : CHANGELOG.md
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écraseplatform/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:checkfait 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_AUTEURest 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_SECRETet les variables d'environnement du serveur (clés Stripe et Anthropic, base). Ne le donnez qu'à qui pourrait administrer la machine. Lescompose.yamlmontent 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:rod'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/consoleen é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).
- 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.
- Le premier chapitre d'un parcours public est ouvert, gratuitement, à tout compte connecté.
- Un administrateur ou un auteur ouvre tout.
- Un parcours sans tarif, ou à 0 €, est ouvert à tout compte : une instance qui ne fixe aucun prix reste gratuite.
- Un chef de cohorte ouvre les parcours de ses cohortes.
- 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:(dansexercise.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 depractice/tout seul, sans liste danspack.yaml.exercise.yamlgarde 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/pratiqueva 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é » etcontent:checkéchoue si l'environnement a une version plus ancienne (lue dans soncomposer.lock) ;pull_request:(URL https, facultative), proposée à la réussite ;visibility: admin(facultative), comme pour un parcours : un exercice en préparation.
access,baseetxpy 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'appelerpratique. La liste/pratiqueest 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,previewetreadonly(facultatives ;readonlyquand un fichier montré en lecture seule a son équivalent dans l'environnement de la langue,lang/en/validation.phpau lieu delang/fr/validation.php). Les mutants traduits gardent leurs ids, que les objectifs citent ; leurssearchvisent 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/ettests/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:checkvérifie chaque traduction comme un exercice à part (pratique/<exercice> [en]). La clésourcerecopie l'empreinte de l'exercice français : quand il change,content:checksignale 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/, sanssummary), et le parcours lui-même dans<parcours>/i18n/en/:track.yaml, avectitle,descriptionetchapters, qui associe à l'id de chaque chapitre sontitle(et sadescriptionsi le chapitre français en a une) ;sourcerecopie 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:checkles 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, etcontent:checkdit ce qui manque. Le parcours de démodecouverteest 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# Titreen 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
/blogni 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 :/blogré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 estupdated, à défautpublished.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=securitene 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_carddu 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 (enclosureetmedia: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 sanspractice: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énementsarticle-exercice, avec sa position, etarticle-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 depractice:, 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 sanscourse:reprend celui de l'article français. Les clics se comptent dans Umami (article-parcours, avec le parcours et la position :colonneoufin).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éclarebiooulinks(voir « Thème » plus bas).Un bloc de code peut s'ouvrir dans le bac à sable : après son langage,
sandbox=php,sandbox=symfonyousandbox=laravel(le fichier ouvert à l'arrivée du bac à sable), ousandbox=symfony:src/Controller/MenuController.phppour 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:checkrefuse 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, plustranslation_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 enx-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 dossieren/.moteur:(danspack.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 »).accessest dépréciée (sans effet depuis la 0.8.0, signalée parcontent: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 soussrc/,migrations/,templates/,config/,tests/outranslations/(par exempledoctrine: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:checkle 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) etMigrationsDeLaBase(vider la base, jouer les migrations jusqu'à une version, comparer le schéma aux entités). setup:liste des commandesbin/consolelancées au chargement de l'aperçu, par exempledoctrine: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}. Unbodyé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.phpsont 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 dansmutants: [{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:checkvé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 lespull_requestde la Pratique sont vérifiés en même temps.- Fiche de cours :
chapters/<chapitre>/lesson.mdré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 lienshttps://; 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:checksignale (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--iaet une clé d'API (voir l'atelier), le modèle rédige un brouillon complet, à relire.--forceremplace 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.pdfregroupe toutes ses fiches derrière une page de garde et un sommaire. - Environnements :
environments/<id>/environment.yamldécrit un projet de base (id,title,php), empaqueté parenvironments/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 parENVIRONMENTS_DIRet ce script (voir environments/README.md).framework:choisit le runtime (symfonypar défaut,laravel,docker,nuxt) : sa console, l'organisation de son projet, ses caches, son lanceur de tests.framework: dockerbascule sur le simulateur Docker (voir plus bas) ;framework: laravelsur les conventions de Laravel : consoleartisan, dossiers (app/,routes/,resources/…), vues Blade compilées dansstorage/framework/viewsvidé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-appest passé de 45 à 26 fichiers,symfony-8-doctrinede 33 à 14). Les clés d'environment.yamls'héritent sauftitle;vendor/ne se superpose pas — il vient du seul dossier de la chaîne qui déclare uncomposer.json, si bien qu'un environnement sans dépendance propre n'a nicomposer.jsonnicomposer.lockà porter. Détails et règles dans environments/README.md. Un fichier.archiveignore(motifszip -x, un par ligne) allège l'archive, par exemple des traductions devendor/inutiles à l'apprenant. Les environnements Laravel forcentAPP_ENV=testingetAPP_RUNNING_IN_CONSOLE=truedansphpunit.xml: Laravel n'exempte les tests de la vérification CSRF que si les deux moitiés derunningInConsole() && runningUnitTests()sont vraies, et la première se déduit dePHP_SAPI, qui vautwasmdans le navigateur (nicliniphpdbg) — sans cela, toutPOSTd'un test de fonctionnalité répond 419. L'aperçu, lui, vérifie le jeton pour de vrai. Leur.envest 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 pourversion:, 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 surphp-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" } }runtimeexporte 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éclareruntime: 'mon-runtime'et arrive par l'étiquette de serviceapp.framework.Le simulateur Nuxt est le premier à passer par ce chemin :
@forelse/simulateur-nuxtporte son runtime, son worker, ses greffons Vite et son lanceur de tests, etplayground/vite.config.tsne 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:checkdoit 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 donctestModuledans 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) etconsoleAliases(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, environnementenvironments/docker) : Docker ne tourne évidemment pas dans le navigateur.packages/simulateur-dockerle 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 devientdocker(sh lancer.shrejoue un script de commandes), et l'aperçu visite les ports publiés :preview: /localhost:8080/. Les tests des exercices étendentForelse\DockerSim\Testing\DockerTestCase(build(),docker(),runScript(),http(),container(),service(),image(),exec(), et les assertionsassertBuildSucceeded,assertImageLacksFile,assertImageSizeBelow,assertPageContains…). Le simulateur est un runtime du moteur, pas un environnement : il reste danstools/, etenvironments/dockeren dépend comme d'une bibliothèque (dépôt Composerpath). Il a sa propre suite de tests (cd packages/simulateur-docker && vendor/bin/phpunit), lancée parmake testet la CI. Limites assumées : pas de registre (docker push), pas de terminal interactif (-it),RUNinterpré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 phasestarting, 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(danstrack.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é parcontent:checket modifiable dans l'atelier. Retirez la clé (ouvisibility: public) pour l'ouvrir. Un administrateur peut aussi l'ouvrir à une seule cohorte en le cochant dans ses parcours disponibles.order: <entier>(danstrack.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 deCONTENT_PACKS_PATHS, puis dossiers de packs par ordre alphabétique, puis listetracks: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>, …](danstrack.yaml) réserve des fichiers deDOWNLOADS_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>(danstrack.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 donccontent: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>, …](danstrack.yaml, ou dans l'exercise.yamld'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(danstrack.yaml) donne le niveau visé par le parcours. Il est repris eneducationalLevel(« 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 detrack.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é detrack.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à sonh1. Un parcours traduit attend sa traduction dansi18n/<langue>/about.md. Un moteur antérieur à la 3.23 l'ignore.next: <parcours>(danstrack.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-devsuit Symfony 8.2 en développement (8.2.x-dev, figé par soncomposer.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 quandsymfony-8passera en 8.2.symfony-8-2-doctrine-devle 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éclareextensions: [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.--keepconserve le projet reconstitué pour l'inspecter. PHPUnit y tourne avecmemory_limit=512Met 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.yamlvalable, 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
THEMEdonne le thème de départ, tant que l'admin n'en a pas choisi un. Sans elle, c'estdefault.
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èmeinstance, actif tant que l'admin n'en a pas choisi un autre. Avant la 2.6,theme.yamls'appelaitmarque.yamlet les gabarits lisaient la variablemarque: 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.chipest la petite puce à côté,taglinel'accroche,titlele<title>de l'accueil,urlle site de la marque,person(name,jobTitle) l'auteur des parcours et des articles dans les données structurées. Avecbio(deux ou trois phrases) oulinks(malt,linkedin,github,site, en https), un encart « Écrit par » suit chaque article ;portrait(une image carrée deassets/) y met une photo,about(du Markdown) remplit sa page/auteurs/<nom>, etlocales.<langue>.persontraduitjobTitle,bioetabout. D'autres auteurs se déclarent sousauthors:, par identifiant (grace-hopper: {name, jobTitle, bio…}, même forme, traduits souslocales.<langue>.authors) ; un article nomme le sien avecauthor: grace-hopper, sinon c'estperson. Chaque auteur qui a une présentation a sa page (/auteurs/grace-hopper,/en/authors/…) : photo, présentation, profils, ses articles, et uneProfilePagedans les données structurées. Pour en faire une vitrine,personaccepte aussilocation(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),expertiseetskills(des listes d'étiquettes, aussiknowsAboutdes données structurées), et des listes de fiches :stats(value,label),services(title,text),press(name,text,url) ettestimonials(quote,author). Toutes se traduisent souslocales.<langue>.person, saufbooking.colorsetfontsredé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. Sanscolors, les pages suivent la préférence claire ou sombre du système ; dès quecolorsest déclaré, elles restent claires, dans la palette du thème.editorhabille 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. Sanseditor, l'éditeur garde la palette neutre du moteur.logo,iconetsharenomment des fichiers de ce dossier (jamais un chemin), servis sur/theme/<thème>/<date>/<rôle>: une image remplacée change d'URL. Sansicon, 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_cardhabille 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, oulogoen 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),fontettext_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'imagesharereste 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.authorethome.demoremplissent 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,scriptsetpreloadlistent des fichiers deassets/(.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 respectantprefers-reduced-motion).replaces_engine_styles: trueretire 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: falsetemplates/: 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_ALLOWLISTrestreint les hôtes acceptés, et une instance qui ne veut pas de la fonctionnalité laisseINSTALLED_ENVIRONMENTS_DIRvide : la page reste, sans formulaire.
- Le dépôt se nomme lui-même : l'identifiant vient de l'
id:de sonenvironment.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-8fonctionne d'un dépôt à l'autre : un environnement installé peut prolonger un environnement du moteur, et n'a alors nicomposer.jsonnivendor/à porter.ENVIRONMENTS_DIRaccepte plusieurs dossiers séparés par des virgules (ceux du moteur, puis ceux de l'instance) ;INSTALLED_ENVIRONMENTS_DIRdésigne celui, écrivable, où l'administration installe.- Les archives d'un environnement installé ne sont pas dans
public/: elles sont servies par/envs/<id>.zipdepuis le dossier des installations. Les archives du moteur restent des fichiers statiques sur la même URL. - Garde-fous :
https://seulement (nifile://, nigit@, nigit://), 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 installdure 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-8dé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) : lesconcepts: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:checksignale une intro que rien n'affiche. L'index/notionsa 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 avectitle,descriptionfacultative etnotions, par leur libellé dansconcepts:) ; 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/conceptsrassemble les notions du seul contenu traduit (exercices de Pratique traduits, parcours entièrement traduits), sous les libellés de la traduction (conceptsdei18n/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) : leversion: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:checksignale 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/pratiquemè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.
lastmodvient 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).PublicPagesSeoTestparcourt toutes les adresses du sitemap et vérifie leurs balises : title unique et dans la limite, description, canonical absolu, Open Graph, un seulh1. 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 avecContent-Security-Policy: connect-src <APP_URL>/envs/ <APP_URL>/build/(docker/Caddyfile) : ils téléchargent leurs archives, mais unfetch('/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 decookie_domain). - Écritures refusées si l'en-tête
Origindiffè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 :
- 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. - La configuration d'une instance — variables d'environnement,
compose.yaml, volumes, base de données. - 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).
Questions fréquentes
Qu'est-ce que PHP, Symfony & Laravel playground ?
Create shareable links to run Symfony, Laravel or plain PHP code in the browser, no install.
Comment installer PHP, Symfony & Laravel playground ?
Il fonctionne comme serveur distant. Dans Claude Code : claude mcp add --transport http php-symfony-laravel-playground 'https://apprendre.forelse.fr/mcp'. Pour les autres clients, collez la configuration JSON de cette page dans leurs réglages MCP.
Faut-il une clé API pour PHP, Symfony & Laravel playground ?
Son entrée au registre n'indique aucune clé ni secret obligatoire. Certaines fonctions peuvent tout de même demander un compte auprès du service auquel il se connecte.
PHP, Symfony & Laravel playground est-il open source ?
Oui. Son code est sur GitHub sous licence AGPL-3.0.
Qui maintient PHP, Symfony & Laravel playground ?
Il est publié par Oipnet. AI.tn le référence sans le maintenir ni le recommander.
Issu du registre MCP officiel. Être listé n'est pas une recommandation : vérifiez le code et les permissions d'un serveur avant de lui donner accès à vos données.