Partager un projet Symfony dans une URL, sans rien stocker
« Tu peux me montrer ton code ? » Sur Slack, la réponse ressemble souvent à un bloc collé de quarante lignes, sans le gabarit Twig qui va avec, sans la route, et sans aucun moyen de l'exécuter. Celui qui veut aider recopie tout dans un projet local, s'aperçoit qu'il manque un fichier, et la conversation s'essouffle.
J'ai ajouté à apprendre.forelse.fr un bac à sable : un projet Symfony 8 ou Laravel 13 vierge, qui tourne dans le navigateur. On modifie un contrôleur, l'aperçu se met à jour, et un bouton « Partager » donne un lien. La personne qui l'ouvre retrouve le projet tel quel, prêt à tourner.
Le plus intéressant n'est pas le bouton, c'est ce qu'il n'y a pas derrière : aucune base de données, aucun stockage, aucun identifiant de partage. Tout le code voyage dans l'URL. Voici comment, et les deux questions que cela pose : la taille et la sécurité.
Le projet tourne déjà dans le navigateur
J'ai détaillé la mécanique dans un article précédent : PHP 8.4 compilé en WebAssembly, un vrai projet Symfony avec son vendor/, et un Service Worker qui sert l'aperçu. Le serveur ne fait que livrer des fichiers statiques.
Le bac à sable réutilise exactement cette machinerie. Pour la plateforme, c'est un exercice sans objectif ni test : un projet de départ, une liste de fichiers modifiables, et c'est tout. L'éditeur, la console (bin/console, php artisan) et l'aperçu sont ceux des exercices.
Il ne restait donc qu'une question : comment transmettre « mon état du projet » à quelqu'un d'autre ?
Pourquoi pas un stockage côté serveur
La solution classique, celle de 3v4l ou de CodePen, consiste à enregistrer le code et à donner un identifiant court : /s/a8Fk2. C'est propre, mais cela a un prix :
- une table qui grossit sans fin, avec du contenu que personne ne modère ;
- une question RGPD à chaque partage, puisque le code peut contenir n'importe quoi ;
- des liens qui meurent le jour où l'on fait le ménage.
Pour un bac à sable pédagogique, je ne voulais rien de tout cela. Et le navigateur offre un endroit parfait pour transporter des données sans les envoyer au serveur : le fragment de l'URL, tout ce qui suit le #.
Le fragment, la partie de l'URL que le serveur ne voit jamais
Quand on ouvre https://apprendre.forelse.fr/bac-a-sable/symfony#code=…, le navigateur demande /bac-a-sable/symfony au serveur, et garde le reste pour lui. Le fragment n'apparaît ni dans la requête HTTP, ni dans les journaux du serveur, ni dans l'en-tête Referer. Seul le JavaScript de la page peut le lire, avec location.hash.
C'est exactement ce qu'il faut : la page publique reste la même pour tout le monde (une seule URL à indexer, une seule canonique), et le code n'existe que dans le lien et dans les navigateurs de ceux qui l'ouvrent.
Une exception à surveiller : la mesure d'audience. Certains traceurs envoient l'URL complète, fragment compris. Le site utilise Umami, qui a une option pour cela (balise simplifiée) :
<script defer src="/mesure/traceur.js" data-website-id="…" data-exclude-hash="true"></script>
Sans elle, le code de chaque visiteur aurait fini dans les statistiques.
Faire tenir un projet dans un lien
Un projet Symfony complet pèse plusieurs mégaoctets, vendor/ compris. Mais tout ce qui ne change pas est déjà connu : c'est le projet de départ, identique pour tout le monde. Le lien ne porte donc que la différence, c'est-à-dire les fichiers modifiés ou créés.
export function changedFiles(current: Record<string, string>, starting: Record<string, string>): Record<string, string> { return Object.fromEntries(Object.entries(current).filter(([path, content]) => starting[path] !== content)); }
Ces fichiers sont mis en JSON, compressés avec deflate (la bibliothèque fflate, déjà présente pour décompresser l'environnement PHP), puis encodés en base64url, la variante de base64 qui n'utilise que des caractères sûrs dans une URL :
export function encodeFragment(files: Record<string, string>): string { const bytes = deflateSync(strToU8(JSON.stringify({ v: 1, files })), { level: 9 }); let binary = ''; for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return 'code=' + btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
Le v: 1 coûte quelques octets, mais il permettra de changer de format sans casser les liens déjà partagés.
En pratique, c'est très raisonnable. Le contrôleur et le gabarit Twig du projet de départ, légèrement modifiés, font environ 1 500 caractères. Une fois compressés et encodés, le fragment en fait un peu plus de 1 000. Le code PHP se compresse bien : beaucoup de mots-clés, de use et d'indentation qui se répètent.
Une contrainte inattendue est venue de Bluesky, qui compte toute l'URL dans ses 300 caractères. Un projet partagé dépasse vite cette limite : dans ce cas, le bouton Bluesky disparaît, et il reste la copie du lien, le partage du téléphone, et LinkedIn et X, qui affichent une adresse raccourcie.
Lire un lien qu'on n'a pas écrit
Le décodage, lui, demande plus de précautions, parce qu'un lien peut avoir été fabriqué par n'importe qui. Trois règles :
Limiter la taille, avant et après décompression. Quelques kilo-octets de deflate peuvent se décompresser en centaines de mégaoctets. Le fragment est refusé au-delà de 200 000 caractères, et la décompression se fait par morceaux, interrompue dès que le total dépasse 1 Mo :
const inflate = new Inflate((chunk) => { size += chunk.length; if (size > MAX_BYTES) throw new RangeError('too-large'); chunks.push(chunk); }); inflate.push(bytes, true);
Refuser les chemins qui sortent du projet. Un fichier nommé ../../etc/passwd ou /racine.php est ignoré. Ce serait sans grand danger dans le système de fichiers en mémoire de PHP, mais il n'y a aucune raison de l'accepter.
N'accepter que les fichiers modifiables. Le lien ne peut écrire que là où le visiteur pourrait écrire lui-même : pour Symfony, src/*/*.php et les gabarits Twig ; pour Laravel, les routes, le code sous app/ et les vues Blade. La configuration du framework n'en fait pas partie : un lien partagé n'a pas à toucher config/packages/security.yaml.
À l'ouverture, le code du lien devient le brouillon du visiteur (et remplace celui qu'il avait pu laisser), et le fragment est retiré de la barre d'adresse avec history.replaceState. Sans ça, recharger la page réimposerait le code du lien par-dessus les modifications faites depuis.
Exécuter le code de quelqu'un d'autre
C'est la question qu'on m'a posée en premier : est-ce bien raisonnable d'envoyer à quelqu'un un lien qui exécute du code dans son navigateur ?
Commençons par ce qui ne risque rien. Le PHP tourne dans un Web Worker, en WebAssembly, sans accès au réseau : la Content Security Policy du worker n'autorise que le téléchargement des environnements et des fichiers du site lui-même. Il ne peut ni lire les fichiers du visiteur, ni appeler une API. Le HTML et le JavaScript produits par le projet s'affichent dans une iframe servie par une autre origine, sans cookie ni accès à la plateforme. C'est la même isolation que pour les exercices, où l'apprenant exécute déjà son propre code.
Mais « isolé » ne veut pas dire « inoffensif ». La page affichée dans l'aperçu peut ressembler à n'importe quoi : une fausse page de connexion, par exemple. Et une boucle infinie peut faire chauffer le processeur.
J'ai donc choisi de ne jamais exécuter automatiquement un code reçu par un lien. Le projet se charge, le code se lit dans l'éditeur, mais l'aperçu affiche d'abord un avertissement :
Ce lien contient du code écrit par quelqu'un d'autre. Lisez-le dans l'éditeur avant de l'exécuter. Il tourne isolé de ce site, mais la page qu'il affiche peut imiter n'importe quoi : n'y saisissez jamais un mot de passe.
L'aperçu ne démarre qu'au clic sur « Exécuter ce code ». Cette friction d'un seul clic est volontaire, et c'est le bon compromis : la personne qui partage un bug veut qu'on le lise, pas qu'on l'exécute à l'aveugle.
Un dernier choix de sécurité : le bac à sable propose Symfony et Laravel, mais pas Nuxt, que la plateforme sait pourtant simuler. Dans le simulateur Nuxt, une partie du code (les routes server/) s'exécute sur l'origine de la plateforme. C'est acceptable pour son propre code, pas pour celui d'un inconnu.
Ce que j'en retiens
Le fragment d'URL est un outil sous-estimé. Il permet de partager un état complet sans backend, sans compte et sans rien conserver, tant que l'état reste de taille raisonnable. Pour un bac à sable de code, c'est exactement le cas : on ne partage presque jamais plus que quelques fichiers.
La contrepartie, c'est qu'un lien devient une entrée non fiable comme une autre. Il faut le traiter comme un formulaire envoyé par un inconnu : limiter, filtrer, et demander l'accord avant d'exécuter.
Vous pouvez essayer le bac à sable Symfony ou le bac à sable Laravel, sans compte. Et la prochaine fois qu'on vous demande votre code sur Slack, envoyez le lien.
Le moteur est open source (AGPL-3.0) : le code de cet article est sur GitHub, dans playground/src/app/scratch.ts et Playground.ts.