Search by

lyrixx / php-wasm-builder

lyrixx

Builds PHP as WebAssembly: a Dockerfile, and castor functions and tasks to build named stacks (a PHP version, extensions, what to embed) with one command

Package info

github.com/lyrixx/php-wasm-builder

pkg:composer/lyrixx/php-wasm-builder

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v0.2.0 2026-10-20 20:02 UTC

This package is auto-updated.

Last update: 2026-10-20 20:03:20 UTC


README

Builds PHP as WebAssembly, for the browser and for node, with the PHP version and the extensions you ask for. Only Docker is needed to build it, and node to run what is built.

This repository started as a fork of derickr/php-wasm-builder, the builder of the PHP WASM files of the php.net documentation, and it is where it is developed now (see Credits).

What is in here:

  • Dockerfile: how PHP is compiled, everything else only drives it;
  • phpw.c: the functions that JavaScript calls to run PHP (phpw_run()…);
  • examples/: a few PHP scripts, embedded in what is built as /examples;
  • run.mjs: runs some PHP code, or a PHP file, in what is built, with node;
  • stacks.php, src/ and castor.php: named builds, a build() function, and castor tasks to build, run and test them (see Stacks, and castor).

Quick start

With Docker only (castor makes it simpler, see Stacks, and castor), build PHP:

docker buildx bake

The files end up in build/default/: php-web.mjs and php-web.wasm for the browser, php-cli.mjs and php-cli.wasm for node. Run PHP in them:

node run.mjs -r 'echo PHP_VERSION;'
node run.mjs examples/hello.php
node run.mjs -r 'require "/examples/hello.php";'

node run.mjs examples/hello.php reads the file from your disk, so you can edit it and run it again without building anything. /examples/hello.php is the copy embedded in the .wasm file: it only changes with a new build.

Everything else is a build argument (see Options). By default, this builds PHP 8.5.11, with the extensions listed in Supported Extensions:

docker buildx bake --set default.args.PHP_VERSION=8.4.26                    # another PHP version
docker buildx bake --set default.args.EXTENSIONS="ctype tokenizer bcmath"   # other extensions
docker buildx bake --set default.args.TARGETS=node                          # only php-cli.mjs and php-cli.wasm

In a browser, php-web.mjs is the same module, built for the web. Serve it next to its php-web.wasm, from any static server:

<pre id="output"></pre>
<script type="module">
    import createPhpModule from './php-web.mjs';

    const output = document.getElementById('output');
    const php = await createPhpModule({
        print: (line) => output.textContent += line + '\n',
    });

    php.ccall('phpw_run', null, ['string'], ['echo "Hello from PHP ", PHP_VERSION;']);
</script>

Options

The following options are available for the baker:

PHP_VERSION
Configures the PHP version to build.
EXTENSIONS

The extensions to build, separated by spaces. Defaults to calendar ctype dom mbstring simplexml xml xmlreader xmlwriter.

The libraries that the extensions need (oniguruma for mbstring, libxml2 for the xml ones) are only built when an extension asks for them.

TARGETS
The files to build: web for php-web.mjs and php-web.wasm, and node for php-cli.mjs and php-cli.wasm. Defaults to web node.
MEMORY
The (initial and maximum) memory of the WASM module. Defaults to 128mb.
EMBED_PATH
The name that the embedded directory has in the WASM filesystem. Defaults to examples.
LIBXML_VERSION
The LibXML version to download and build.
ONIGURUMA_VERSION
The Oniguruma library (used for regular expressions with mbstring) version to download and build.
ICU_VERSION
The ICU library (used by intl) version to download and build, for example 78.3.
EMSDK_VERSION
The version of the Emscripten SDK that compiles everything, a tag of the emscripten/emsdk image. It is pinned, so that the same arguments always give the same build.

The directory to embed in the WASM filesystem is the embed named build context. docker-bake.hcl sets it to examples/; anything can be used instead:

docker buildx bake --set default.contexts.embed=./my-app --set default.args.EMBED_PATH=my-app

Supported Extensions

These are the extensions that can be listed in EXTENSIONS:

  • bcmath
  • calendar
  • ctype
  • dom
  • filter
  • intl (see intl)
  • libxml
  • mbstring
  • SimpleXML
  • tokenizer
  • xml
  • xmlreader
  • xmlwriter

They are always there, whatever EXTENSIONS says:

  • Core
  • date
  • hash
  • json
  • lexbor (PHP 8.5 and later)
  • pcre
  • random
  • Reflection
  • SPL
  • standard
  • uri (PHP 8.5 and later)
  • Zend OPcache (PHP 8.5 and later)

To add an extension, add a line to the ext list at the top of the Dockerfile (and a stage for the library that it needs, if it needs one).

intl

intl is linked statically, with ICU and all of its data (every locale): there is no intl.so to load, nor data file to fetch, and it works the same on the web and on node. It does not come cheap, which is why it is not in the default extensions: the .wasm file is about 33 MB bigger (14 MB gzipped), and the data takes 32 MB of the memory of the module. Add it to the extensions, with 32 MB more memory:

docker buildx bake --set default.args.EXTENSIONS="calendar ctype dom mbstring simplexml xml xmlreader xmlwriter intl" --set default.args.MEMORY=160mb
node run.mjs examples/intl.php

The intl stack (see Stacks, and castor) is the same build.

Limitations

  • Fibers
    WebAssembly cannot pause and resume code the way Fibers require, so the build uses --disable-fiber-asm.

Stacks, and castor

A stack is a PHP version, a set of extensions, what to embed and which targets to build, with a name. The stacks are declared in stacks.php, and castor builds them with one command; castor only runs docker buildx bake for you, the Dockerfile is what knows how to compile PHP. Nobody needs castor to use this repository, and no PHP is needed on the host: castor is a single binary, installed with curl "https://castor.jolicode.com/install" | bash.

<?php

use PhpWasm\Stack;

return [
    Stack::named('default')
        ->php('8.5.11')
        ->extensions('calendar', 'ctype', 'dom', 'mbstring', 'simplexml', 'xml', 'xmlreader', 'xmlwriter')
        ->embed('examples')
        ->targets('web', 'node'),

    // Inherits everything from default, and adds two extensions
    Stack::named('default-bcmath')->from('default')->extensions('bcmath', 'tokenizer'),
];

A stack that inherits from another one with from() gets whatever it does not declare from it. The extensions it declares are added to the ones of its parent, the rest replaces what the parent says. embed() is relative to the directory of the stacks.php file.

Everything is built in build/<stack>/:

castor wasm:stacks                         # lists the stacks
castor wasm:build                          # builds the default stack
castor wasm:build intl                     # builds another one
castor wasm:build --all                    # builds all of them, in one buildx invocation
castor wasm:test                           # builds them all, and checks them
castor wasm:sizes                          # compares the size of the .wasm files with the default stack
castor wasm:run examples/hello.php         # runs a file, in the default stack
castor wasm:run -r 'echo PHP_VERSION;' --stack php-8.3

Anything in a stack can be overridden from the command line, so there is no need to declare a stack to try something:

castor wasm:build --php 8.3.35                      # the default stack, on PHP 8.3
castor wasm:build playground --target node          # only the node target
castor wasm:build --with tokenizer --with bcmath    # the default stack, and two more extensions
castor wasm:build --memory 256mb --embed my-app     # more memory, another directory to embed
castor wasm:build --print                           # only shows the bake file

What is built with overrides goes to the directory of the stack (build/default/, build/playground/), in place of what was there.

castor wasm:test is what tells that a stack works: once built, it runs each stack in node, and checks that PHP_VERSION is the one that was asked for, and that extension_loaded() is true for every extension that was asked for. It does not run what the stack embeds.

Playing with it

These are the stacks of stacks.php:

  • default: PHP 8.5, with the default extensions, what docker buildx bake builds too;
  • php-8.4 and php-8.3: the same thing, on older PHP versions;
  • playground: adds bcmath, filter and tokenizer;
  • intl: adds intl (see intl).

Each script of examples/ says which stack it needs:

castor wasm:run examples/hello.php
castor wasm:run examples/xml.php --stack php-8.3
castor wasm:build playground
castor wasm:run examples/playground.php --stack playground
castor wasm:build intl
castor wasm:run examples/intl.php --stack intl

Using it from your own project

The Dockerfile knows which emsdk works, how to cross-compile oniguruma and libxml2, which ./configure flags PHP needs to link and which emcc flags make it load. Any PHP project can reuse that, instead of copying the Dockerfile, by requiring this repository as a castor package:

castor composer require lyrixx/php-wasm-builder

Declare your stacks, with the same format, in a stacks.php file next to castor.php. The stacks of this repository are there to inherit from:

<?php
// stacks.php

use PhpWasm\Stack;

return [
    Stack::named('my-app')
        ->from('default')
        ->php('8.5.11')
        ->extensions('tokenizer', 'bcmath')
        ->embed('dist'),    // relative to your project
];

Then build them from your own tasks, with PhpWasm\build(). It builds them in the build/<stack>/ directory of your project:

<?php
// castor.php

use Castor\Attribute\AsTask;

use function PhpWasm\build;

defined('CASTOR_USE_CHDIR') || define('CASTOR_USE_CHDIR', false);

#[AsTask]
function wasm(): void
{
    build('my-app');
}

Requiring the package does not add any task to your project. To get the ones of this repository too (castor wasm:build my-app, castor wasm:run, castor wasm:test…), import it in your castor.php:

use function Castor\import;

import('composer://lyrixx/php-wasm-builder');

Credits

This builder was created by Derick Rethans, for The PHP Foundation, in derickr/php-wasm-builder. Its shim, phpw.c, comes from the work of Antoine Bluchet on PHP in WebAssembly. Both are under the MIT license, see LICENSE.

Thanks to the people whose work on the original repository is part of this one: Lewis Cowles, Arnaud Le Blanc, Jordi Kroon, and Marc (henderkes), for the work on the intl extension in derickr/php-wasm-builder#7 and #8.