Running Symfony in the browser with WebAssembly

Arnaud Pointet · · 7 min read

Lire cet article en français

When you're learning Symfony, the first hour is rarely spent on Symfony. It's spent installing PHP and Composer, hunting down a missing extension, and fighting a local server that refuses to start. For apprendre.forelse.fr, I wanted a learner to open a page and write their first controller within a minute. Not a simulated version of Symfony: the real framework, with its router, Twig, the exception page and the profiler, and PHPUnit.

And I didn't want to run learners' code on my servers. Hosting arbitrary code means one container per person, a bill that grows with every sign-up, and an attack surface you never truly close.

The solution: run PHP in the browser. Here's how it works, what it costs, and the pitfalls I hit along the way.

What the learner sees

An exercise page has three areas:

  • an editor (Monaco, the one behind VS Code) with the project's files;
  • a preview of the application, which reloads on every change;
  • a button to run the tests, each of which maps to one of the exercise's goals.

Behind it sits a real Symfony 8.1 project, vendor/ included, running on PHP 8.4. The server only serves static files and saves progress. Not a single line written by the learner runs on the server.

PHP compiled to WebAssembly

The core building block already exists: php-wasm, the project that powers WordPress Playground. It's the official PHP interpreter, compiled to WebAssembly with Emscripten, with an in-memory file system.

You give it a project, pass it an HTTP request (method, URL, headers, body), it runs public/index.php and returns a response. From Symfony's point of view, nothing changes: it's a front controller receiving a request.

The Symfony project itself ships as a zip archive, prepared ahead of time with composer install. The browser downloads it, unpacks it into PHP's file system, then layers the exercise's starter files on top.

A few numbers, measured in Chrome during the proof of concept:

Step Time
PHP runtime ready (download, unpacking, startup), with HTTP cache 0.34 s
First Symfony page (container compilation) ~0.45 s
Reload after a change 40 to 80 ms
Suite of 5 WebTestCase tests, after the first run ~0.1 s

In terms of size, the PHP binary weighs 19 MB (7.6 MB compressed), and the Symfony environment 4.9 MB. That's heavy for a web page, but you only pay it once: after that, the browser cache takes over.

First pitfall: compiling PHP only once

Each exercise needs two PHP instances: one for the preview (dev environment) and one for the tests (test environment). Interleaving HTTP requests and PHPUnit runs on a single instance would eventually lock it up, and keeping them separate stops the tests from polluting the preview.

The catch is that, by default, each instance downloads and compiles its own 19 MB binary. The fix is to compile the WebAssembly module once, then instantiate it as many times as needed through Emscripten's instantiateWasm hook:

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;
}

// For each instance:
const emscriptenOptions = {
	instantiateWasm(imports, receive) {
		WebAssembly.instantiate(wasm, imports).then((instance) => receive(instance, wasm));
		return {};
	},
};

A welcome side effect: the test instance warms up in the background while the learner reads the instructions. Once the page has loaded, nothing depends on the network anymore.

Second pitfall: displaying the application in an iframe

Running PHP is one thing. Displaying the result like a real website, with working links, POST forms, redirects, cookies and CSS files, is another. Writing the generated HTML into an iframe isn't enough: the first time you click a link, the browser goes off to fetch the page… from the server.

That's where a Service Worker comes in. It intercepts every request from the preview iframe under /preview/… and, instead of sending it over the network, hands it to 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)); // → PHP worker → Response
});

To make Symfony generate correct URLs, we tell it it's being served from under that prefix (SCRIPT_NAME=/preview/index.php). Routes, path() in Twig, redirects and the files in public/ all follow along on their own.

There was one detail to handle: Origin and Referer are added by the browser after the Service Worker, and Host is a forbidden header name. Without them, Symfony's CSRF protection rejects forms. So the Service Worker rebuilds them before forwarding the request.

Third pitfall: isolating the learner's code

The code the learner writes produces HTML, and therefore potentially JavaScript. If it ran on the same origin as the platform, it could read the session, call the API on the user's behalf, or worse.

So the preview lives on a separate origin, the sandbox, which has access to nothing:

Exercise page (platform) ── PHP worker: "preview" instance + "tests" instance
      │  postMessage (origin checked)
      ▼
/sandbox relay (sandbox origin) ── Service Worker ── preview iframe

The Service Worker is registered on the sandbox, never on the platform. It hands each request to a relay page on its own origin, which passes it on to the platform via postMessage. The platform only accepts messages that come from that relay and from the sandbox origin. Even if the learner's code takes over the relay, all it can do is ask for its own PHP code to be run: no platform data ever flows the other way.

One last case to cover: a while (true) in the learner's JavaScript freezes the iframe. So the platform sends the relay a heartbeat every two seconds. If there's no answer for fifteen seconds, the iframe is removed and recreated, without reloading the page that got stuck in the loop (it would just fall right back into it).

Validating an exercise with PHPUnit

An exercise is a set of instructions, some starter files, and hidden PHPUnit tests. Each test method maps to a goal shown to the learner: "the /menu route returns 200", "the page lists all three dishes", and so on.

Running PHPUnit in the browser requires nothing special: it's just PHP. To read the result reliably, the runner doesn't parse the text output. It asks for a JUnit report, reads it with SimpleXML and returns 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 or skipped, with the failure message
}

Symfony's WebTestCase works as is: the test client sends its requests straight into the kernel, with no network involved. A suite of five tests takes about 100 ms once the first run is out of the way.

What about autocompletion, without a language server?

An editor without completion is frustrating when you're learning a framework. But running a PHP language server in the browser would have doubled the page weight.

So the completion index is generated ahead of time, using Reflection, when the environment is packaged. It describes the classes and methods available in the project. It weighs about 80 KB compressed and covers the cases that matter for a beginner:

  • $this-> in a controller (the AbstractController methods);
  • $client->request() returning a Crawler;
  • attributes after #[;
  • automatically adding the use statement;
  • in Twig, the project's routes after path('.

What it doesn't do

Not everything is possible in a browser, and I'd rather say so up front:

  • No real server database. No MySQL or PostgreSQL: Doctrine runs on SQLite, which is more than enough to learn entities, relationships and migrations.
  • No Redis, no Messenger with a real transport, no long-running processes. proc_open doesn't exist: a command that spawns another program finishes without doing anything.
  • A cost on first load. Around ten megabytes to download the first time. That's acceptable on a desktop, more debatable on a mobile connection.

For topics outside that scope (Docker, Nuxt), the platform uses dedicated simulators behind the same runtime interface. But that's another story.

My takeaways

WebAssembly has changed what you can reasonably do in a browser. A few years ago, running a full PHP framework client-side was a tech demo. Today, it holds up in production, in a few hundred milliseconds, at a server cost of roughly zero.

For a learning platform, the win is twofold: the learner installs nothing and gets started right away, and I have no arbitrary code to host.

The engine is open source (AGPL-3.0) and self-hostable. If you want to see it in action, the Practice exercises launch in one click, no account needed. And if you want to look under the hood, the code is on GitHub.

← All articles