How to create a PHP Playground for your documentation

Since I gave the talk “True Serverless or how to run PHP in the browser to document an open source project” and released the API Platform Playground, many asked to build their own. I’ll show here how to use php and WASM to build your own playground.

Let’s build this PHP playground in 43 lines of code.


There’s some history about PHP In Browser by oraoto and PHP Wasm by seanmorris, I mainly continued working on this at PHP Wasm where the build got improved with Docker steroids.

We use Emscripten to build PHP and target Webassembly so that you can run php index.php in a web page.

First step: get the PHP WASM build

# Get my last image (contains libxml and sqlite)
docker pull soyuka/php-wasm:8.2.10
# Create a container
docker create --name=php-wasm soyuka/php-wasm:8.2.10
# Our build goes to `php-wasm`
mkdir -p public/ dist/
# Copy the build of PHP Webassembly
docker cp php-wasm:/build/php-web.mjs ./dist
docker cp php-wasm:/build/php-web.wasm ./public

We use the Emscripten module (mjs) build. If you want to change this you need to head to php-wasm README.

Second step: preload data

To run PHP we need PHP code. Let’s prepare a directory with an index.php file:

# src/index.php


Then we want to integrate this to our php-web.mjs build. We use three different volumes, one for source code (src), one for our output (dist) and our public directory:

docker run -v $(pwd)/src:/src -v $(pwd)/public:/public -v $(pwd)/dist:/dist php-wasm python3 /emsdk/upstream/emscripten/tools/file_packager.py /public/php-web.data --use-preload-cache --lz4 --preload "/src" --js-output=/dist/php-web.data.js --no-node --exclude '*/.*' --export-name=createPhpModule

Because the way emscripten JavaScript shell works we need to copy the new php-web.data.js inside the php-web.mjs we built on first step:

sed '/--pre-js/r dist/php-web.data.js' dist/php-web.mjs > public/php-web.mjs

Third step: run PHP

Create a public/index.html file where you run:

<!-- public/index.html -->
<iframe id="output" width="100%" height="50%" frameBorder="0"></iframe>
<script type="module">
	import phpBinary from "./php-web.mjs";

	async function main() {
		const output = document.getElementById('output')
		const buffer = [];
		const {ccall, FS} = await phpBinary({
			print(data) {

		console.log(ccall("phpw_exec", "string", ["string"], ["phpversion();"]));

		window.FS = FS;
		window.run = () => {
			// Note that `/src` is the path we used when preloading!
			ccall("phpw", null, ["string"], ["/src/index.php"]);
			output.contentWindow.document.body.innerHTML = buffer.join('');
			buffer.length = 0;



I usually run a Caddy file-server:

caddy file-server --listen localhost:8080 --root public

Last step: add some JavaScript

Let’s add a Monaco (VSCode) editor

<div id="monaco" style="min-height: 100px"></div>
<script type="module">
	import * as monaco from 'https://cdn.jsdelivr.net/npm/monaco-editor@0.39.0/+esm';
	const editor = monaco.editor.create(document.getElementById('monaco'));
	const model = editor.getModel();
	model.setValue(window.FS.readFile('/src/index.php', {encoding: 'utf8'}).toString());
	model.onDidChangeContent((v) => {
		window.FS.writeFile('/src/index.php', model.getValue());

Note that FS is documented at https://emscripten.org/docs/api_reference/Filesystem-API.html and everything is synchronous.

You can see this live at: https://soyuka.github.io/php-wasm/

On the API Platform playground we use React, the logic remains the same.

If you like the work consider sponsoring.