Faire tourner Symfony dans le navigateur avec WebAssembly
Quand on apprend Symfony, la première heure part rarement dans Symfony. Elle part dans l'installation de PHP, de Composer, d'une extension qui manque, du serveur local qui refuse de démarrer. Pour apprendre.forelse.fr, je voulais qu'un apprenant ouvre une page et écrive son premier contrôleur dans la minute. Pas une version simulée de Symfony : le vrai framework, avec son routeur, Twig, le profiler d'exceptions et PHPUnit.
Et je ne voulais pas exécuter le code des apprenants sur mes serveurs. Héberger du code arbitraire, c'est un conteneur par personne, une facture qui grossit avec chaque inscrit et une surface d'attaque qu'on ne referme jamais vraiment.
La solution : faire tourner PHP dans le navigateur. Voici comment ça marche, ce que ça coûte, et les pièges rencontrés en route.
Ce que voit l'apprenant
Sur une page d'exercice, trois zones :
- un éditeur (Monaco, celui de VS Code) avec les fichiers du projet ;
- un aperçu de l'application, qui se recharge à chaque modification ;
- un bouton pour lancer les tests, dont chacun correspond à un objectif de l'exercice.
Derrière, il y a un vrai projet Symfony 8.1, vendor/ compris, qui tourne sur PHP 8.4. Le serveur ne sert que des fichiers statiques et enregistre la progression. Aucune ligne écrite par l'apprenant n'est exécutée côté serveur.
PHP compilé en WebAssembly
La brique de base existe déjà : php-wasm, le projet qui fait tourner WordPress Playground. C'est l'interpréteur PHP officiel, compilé en WebAssembly avec Emscripten, qui dispose d'un système de fichiers en mémoire.
On lui donne un projet, on lui passe une requête HTTP (méthode, URL, en-têtes, corps), il exécute public/index.php et renvoie une réponse. Du point de vue de Symfony, rien ne change : c'est un front controller qui reçoit une requête.
Le projet Symfony lui-même est livré sous forme d'archive zip, préparée à l'avance avec composer install. Le navigateur la télécharge, la décompresse dans le système de fichiers de PHP, puis y superpose les fichiers de départ de l'exercice.
Quelques chiffres, mesurés sur Chrome pendant la preuve de concept :
| Étape | Temps |
|---|---|
| Runtime PHP prêt (téléchargement, décompression, démarrage), avec cache HTTP | 0,34 s |
| Première page Symfony (compilation du container) | ~0,45 s |
| Rechargement après une modification | 40 à 80 ms |
Suite de 5 tests WebTestCase, après le premier lancement |
~0,1 s |
Côté volume, le binaire PHP pèse 19 Mo (7,6 Mo compressé), et l'environnement Symfony 4,9 Mo. C'est lourd pour une page web, mais c'est payé une seule fois : ensuite, le cache du navigateur prend le relais.
Premier piège : compiler PHP une seule fois
Il faut deux instances PHP par exercice : une pour l'aperçu (environnement dev), une pour les tests (environnement test). Enchaîner des requêtes HTTP et des lancements de PHPUnit sur une seule instance finissait par la bloquer, et les séparer évite que les tests polluent l'aperçu.
Or, par défaut, chaque instance télécharge et compile son propre binaire de 19 Mo. La solution est de compiler le module WebAssembly une fois, puis de l'instancier autant de fois que nécessaire grâce au hook instantiateWasm d'Emscripten :
let compiledPhp: Promise<WebAssembly.Module> | undefined; function compilePhpWasm(version: '8.4'): Promise<WebAssembly.Module> { compiledPhp ??= getPHPLoaderModule(version).then(({ dependencyFilename }) => WebAssembly.compileStreaming(fetch(dependencyFilename)), ); return compiledPhp; } // Pour chaque instance : const emscriptenOptions = { instantiateWasm(imports, receive) { WebAssembly.instantiate(wasm, imports).then((instance) => receive(instance, wasm)); return {}; }, };
Effet de bord bienvenu : l'instance de tests se prépare en arrière-plan pendant que l'apprenant lit la consigne. Une fois la page chargée, plus rien ne dépend du réseau.
Deuxième piège : afficher l'application dans une iframe
Exécuter PHP, c'est une chose. Afficher le résultat comme un vrai site, avec des liens qui fonctionnent, des formulaires en POST, des redirections, des cookies et des fichiers CSS, c'en est une autre. Écrire le HTML produit dans une iframe ne suffit pas : au premier clic sur un lien, le navigateur part chercher la page… sur le serveur.
C'est là qu'intervient un Service Worker. Il intercepte toutes les requêtes de l'iframe d'aperçu sous /preview/… et, au lieu de les envoyer sur le réseau, les fait exécuter par PHP :
self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (url.origin !== self.location.origin || !url.pathname.startsWith('/preview/')) return; event.respondWith(forward(event.request, url)); // → worker PHP → Response });
Pour que Symfony génère des URL correctes, on lui fait croire qu'il est servi depuis ce préfixe (SCRIPT_NAME=/preview/index.php). Les routes, path() dans Twig, les redirections et les fichiers de public/ suivent tout seuls.
Il a fallu gérer un détail : Origin et Referer sont ajoutés par le navigateur après le Service Worker, et Host fait partie des en-têtes interdits. Sans eux, la protection CSRF de Symfony refuse les formulaires. Le Service Worker les reconstitue donc avant de transmettre la requête.
Troisième piège : isoler le code de l'apprenant
Le code que l'apprenant écrit produit du HTML, et donc potentiellement du JavaScript. S'il s'exécutait sur la même origine que la plateforme, il pourrait lire la session, appeler l'API au nom de l'utilisateur, ou pire.
L'aperçu vit donc sur une origine séparée, le bac à sable, qui n'a accès à rien :
Page d'exercice (plateforme) ── worker PHP : instance « aperçu » + instance « tests »
│ postMessage (origine vérifiée)
▼
Relais /sandbox (origine bac à sable) ── Service Worker ── iframe d'aperçu
Le Service Worker est enregistré sur le bac à sable, jamais sur la plateforme. Il transmet chaque requête à une page relais sur la même origine que lui, qui la fait suivre à la plateforme par postMessage. La plateforme n'accepte que les messages qui viennent de ce relais et de l'origine bac à sable. Même si le code de l'apprenant prend la main sur le relais, il ne peut que demander l'exécution de son propre PHP : aucune donnée de la plateforme ne transite dans l'autre sens.
Dernier cas à couvrir : un while (true) dans le JavaScript de l'apprenant gèle l'iframe. La plateforme envoie donc un battement de cœur au relais toutes les deux secondes. Sans réponse pendant quinze secondes, l'iframe est retirée puis recréée, sans recharger la page qui a bouclé (elle retomberait dans la boucle).
Valider un exercice avec PHPUnit
Un exercice, c'est une consigne, des fichiers de départ, et des tests PHPUnit cachés. Chaque méthode de test correspond à un objectif affiché à l'apprenant : « la route /menu répond 200 », « la page affiche les trois plats », etc.
Lancer PHPUnit dans le navigateur ne demande rien de particulier : c'est du PHP. Pour lire le résultat de façon fiable, le lanceur ne décode pas la sortie texte. Il demande un rapport JUnit, le lit avec SimpleXML et renvoie un JSON :
$argv = ['phpunit', '--colors=never', '--do-not-cache-result', '--log-junit', '/tmp/junit.xml', ...$paths]; $exitCode = (new PHPUnit\TextUI\Application())->run($argv); foreach ($xml->xpath('//testcase') as $testcase) { // passed, failed, error ou skipped, avec le message d'échec }
Les WebTestCase de Symfony fonctionnent tels quels : le client de test fait ses requêtes directement dans le kernel, sans réseau. Une suite de cinq tests prend environ 100 ms une fois la première exécution passée.
Et l'autocomplétion, sans serveur de langage ?
Un éditeur sans complétion, pour apprendre un framework, c'est frustrant. Mais faire tourner un serveur de langage PHP dans le navigateur aurait doublé le poids de la page.
L'index de complétion est donc généré à l'avance, par Reflection, au moment où l'environnement est empaqueté. Il décrit les classes et méthodes disponibles dans le projet. Il pèse environ 80 Ko compressé et couvre les cas qui comptent pour un débutant :
$this->dans un contrôleur (les méthodes d'AbstractController) ;$client->request()qui renvoie unCrawler;- les attributs après
#[; - l'ajout automatique du
use; - dans Twig, les routes du projet après
path('.
Ce que ça ne fait pas
Tout n'est pas possible dans un navigateur, et autant le dire :
- Pas de vraie base de données serveur. Pas de MySQL ni de PostgreSQL : Doctrine tourne sur SQLite, ce qui suffit largement pour apprendre les entités, les relations et les migrations.
- Pas de Redis, pas de Messenger avec un vrai transport, pas de processus longs.
proc_openn'existe pas : une commande qui lance un autre programme se termine sans rien faire. - Un coût au premier chargement. Une dizaine de mégaoctets à télécharger la première fois. C'est acceptable sur ordinateur, plus discutable sur une connexion mobile.
Pour les sujets qui sortent de ce cadre (Docker, Nuxt), la plateforme utilise des simulateurs dédiés, derrière la même interface de runtime. Mais c'est une autre histoire.
Ce que j'en retiens
WebAssembly a changé ce qu'on peut raisonnablement faire dans un navigateur. Il y a quelques années, faire tourner un framework PHP complet côté client relevait de la démo technique. Aujourd'hui, ça tient en production, en quelques centaines de millisecondes, pour un coût serveur à peu près nul.
Pour une plateforme pédagogique, le gain est double : l'apprenant n'installe rien et commence tout de suite, et je n'ai aucun code arbitraire à héberger.
Le moteur est open source (AGPL-3.0) et auto-hébergeable. Si vous voulez voir le résultat, les exercices de Pratique se lancent en un clic, sans compte. Et si vous voulez regarder sous le capot, le code est sur GitHub.