Sharing a Symfony project in a URL, without storing anything
"Can you show me your code?" On Slack, the answer is usually a forty-line paste, without the Twig template that goes with it, without the route, and with no way to run it. Whoever wants to help copies it all into a local project, notices a file is missing, and the conversation fizzles out.
I added a playground to apprendre.forelse.fr: a blank Symfony 8 or Laravel 13 project that runs in the browser. You edit a controller, the preview updates, and a "Share" button gives you a link. Whoever opens it gets the project as it is, ready to run.
The interesting part isn't the button, it's what isn't behind it: no database, no storage, no share ID. All the code travels in the URL. Here is how, and the two questions it raises: size, and security.
The project already runs in the browser
I described the mechanics in a previous article: PHP 8.4 compiled to WebAssembly, a real Symfony project with its vendor/, and a Service Worker serving the preview. The server only delivers static files.
The playground reuses exactly that machinery. To the platform, it's an exercise with no objective and no test: a starting project, a list of editable files, and that's it. The editor, the console (bin/console, php artisan) and the preview are the same as in the exercises.
That left one question: how do you hand your copy of the project to someone else?
Why not server-side storage
The classic answer, the one 3v4l and CodePen use, is to save the code and hand out a short ID: /s/a8Fk2. It's clean, but it has a cost:
- a table that grows forever, full of content nobody moderates;
- a GDPR question with every share, since the code can contain anything;
- links that die the day you clean up.
For a learning playground, I wanted none of that. And the browser offers a perfect place to carry data without sending it to the server: the URL fragment, everything after the #.
The fragment, the part of the URL the server never sees
When you open https://apprendre.forelse.fr/en/playground/symfony#code=…, the browser asks the server for /en/playground/symfony and keeps the rest to itself. The fragment shows up neither in the HTTP request, nor in the server logs, nor in the Referer header. Only the page's JavaScript can read it, through location.hash.
That's exactly what's needed: the public page stays the same for everyone (one URL to index, one canonical), and the code only exists in the link and in the browsers of the people who open it.
One exception to watch: analytics. Some trackers send the full URL, fragment included. The site uses Umami, which has an option for that (tag simplified):
<script defer src="/mesure/traceur.js" data-website-id="…" data-exclude-hash="true"></script>
Without it, every visitor's code would have ended up in the stats.
Fitting a project in a link
A full Symfony project weighs several megabytes, vendor/ included. But everything that doesn't change is already known: it's the starting project, the same for everyone. So the link only carries the difference, that is the files changed or created.
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)); }
Those files are turned into JSON, compressed with deflate (the fflate library, already there to unpack the PHP environment), then encoded in base64url, the base64 variant that only uses URL-safe characters:
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(/=+$/, ''); }
The v: 1 costs a few bytes, but it will let the format change without breaking links already shared.
In practice, it's very reasonable. The starting project's controller and Twig template, slightly edited, come to about 1,500 characters. Compressed and encoded, the fragment is a little over 1,000 characters. PHP code compresses well: lots of repeated keywords, use statements and indentation.
One unexpected constraint came from Bluesky, which counts the whole URL in its 300 characters. A shared project quickly goes over: in that case the Bluesky button disappears, and you still have copy link, the phone's share sheet, and LinkedIn and X, which display a shortened address.
Reading a link you didn't write
Decoding needs more care, because anyone can craft a link. Three rules:
Cap the size, before and after decompression. A few kilobytes of deflate can inflate to hundreds of megabytes. The fragment is rejected beyond 200,000 characters, and decompression runs chunk by chunk, stopped as soon as the total exceeds 1 MB:
const inflate = new Inflate((chunk) => { size += chunk.length; if (size > MAX_BYTES) throw new RangeError('too-large'); chunks.push(chunk); }); inflate.push(bytes, true);
Reject paths that leave the project. A file named ../../etc/passwd or /root.php is ignored. It would be fairly harmless in PHP's in-memory file system, but there is no reason to accept it.
Only accept editable files. The link can only write where the visitor could write themselves: for Symfony, src/*/*.php and the Twig templates; for Laravel, the routes, the code under app/ and the Blade views. The framework configuration isn't part of it: a shared link has no business touching config/packages/security.yaml.
On opening, the link's code becomes the visitor's draft (replacing any they had left), and the fragment is removed from the address bar with history.replaceState. Otherwise, reloading the page would force the link's code back over the changes made since.
Running someone else's code
That was the first question I got: is it really reasonable to send someone a link that runs code in their browser?
Let's start with what's safe. PHP runs in a Web Worker, in WebAssembly, with no network access: the worker's Content Security Policy only allows fetching the environments and the site's own build files. It can't read the visitor's files or call an API. The HTML and JavaScript the project produces are shown in an iframe served from another origin, with no cookie and no access to the platform. It's the same isolation as for the exercises, where learners already run their own code.
But "isolated" doesn't mean "harmless". The page shown in the preview can look like anything: a fake login page, for instance. And an infinite loop can make the CPU spin.
So I chose to never automatically run code received through a link. The project loads and the code can be read in the editor, but the preview first shows a warning:
This link contains code written by someone else. Read it in the editor before running it. It runs isolated from this site, but the page it shows can imitate anything: never type a password into it.
The preview only starts when you click "Run this code". That one click is deliberate friction, and the right trade-off: someone sharing a bug wants it read, not run blindly.
One last security choice: the playground offers Symfony and Laravel, but not Nuxt, even though the platform can simulate it. In the Nuxt simulator, part of the code (the server/ routes) runs on the platform's origin. That's fine for your own code, not for a stranger's.
What I take away
The URL fragment is an underrated tool. It lets you share a complete state with no backend, no account and nothing kept, as long as the state stays reasonably small. For a code playground, that's exactly the case: you almost never share more than a few files.
The flip side is that a link becomes untrusted input like any other. Treat it like a form sent by a stranger: cap, filter, and ask before running.
You can try the Symfony playground or the Laravel playground, no account needed. And next time someone asks for your code on Slack, send the link.
The engine is open source (AGPL-3.0): the code from this article is on GitHub, in playground/src/app/scratch.ts and Playground.ts.