wordpress/compile-php-wasm
Compile PHP.wasm main modules and side modules (dynamic extensions) for Node.js and web platforms. Use when recompiling PHP, adding Emscripten flags, modifying Dockerfiles, building extensions as SIDE_MODULE, upgrading Emscripten, or troubleshooting compilation failures.
npx skills add https://github.com/WordPress/wordpress-playground --skill compile-php-wasm
Patterns for compiling PHP.wasm main modules and dynamic extension side
modules in the WordPress Playground repository.
Requires: Docker, Node.js (version from .nvmrc), npm
npx nx recompile-php:all php-wasm-web -- --PHP_VERSION=8.5
npx nx recompile-php:all php-wasm-node -- --PHP_VERSION=8.5
npx nx recompile-php:jspi php-wasm-web -- --PHP_VERSION=8.5
npx nx recompile-php:asyncify php-wasm-web -- --PHP_VERSION=8.5
npx nx recompile-php:jspi php-wasm-node -- --PHP_VERSION=8.5
npx nx recompile-php:asyncify php-wasm-node -- --PHP_VERSION=8.5
npm run recompile:php:web
npm run recompile:php:node
RuntimeError: unreachable where you need to identify whichnpx nx recompile-php:all php-wasm-web -- --WITH_DEBUG=yes
npx nx recompile-php:all php-wasm-node -- --WITH_DEBUG=yes
npx nx recompile-php:all php-wasm-web -- --WITH_SOURCEMAPS=yes
npx nx recompile-php:all php-wasm-node -- --WITH_SOURCEMAPS=yes
node node_modules/.bin/nx reset
docker rmi php-wasm:latest
The build system lives in packages/php-wasm/compile/. The pipeline is:
Dockerfile (Emscripten + PHP source + patches)
↓ docker build
.wasm binary + .js glue file
↓ post-link Dockerfile patches (replace.sh)
Patched .js glue file
↓ NX executor copies to dist/
Final artifacts in package dist/
Key files:
| File | Purpose |
|------|---------|
| Dockerfile | Main build — downloads PHP source, applies patches, runs emcc |
| Makefile | Orchestrates Docker builds per PHP version |
| build.js | Node script invoked by NX executors |
| .emcc-php-wasm-flags | Emscripten linker flags (generated during build) |
| .emcc-php-wasm-sources | Source/library paths for linking (generated during build) |
| replace.sh / Dockerfile RUN sed | Post-link patches to the compiled JS glue file |
| Flag | Purpose |
|------|---------|
| ASYNCIFY | Enables Asyncify (unwind/rewind for async JS calls) |
| ASYNCIFY_ONLY=[func1,func2,...] | Whitelist of functions Asyncify instruments (critical for binary size) |
| JSPI | Enables JSPI (V8 native stack switching, replaces Asyncify) |
| JSPI_IMPORTS=[func1,...] | JS imports wrapped with WebAssembly.Suspending |
| JSPI_EXPORTS=[func1,...] | WASM exports wrapped with WebAssembly.promising |
| MAIN_MODULE=1 | Enables dlopen — exports all symbols for dynamic linking |
| MAIN_MODULE=2 | Like =1 but only exports explicitly listed symbols |
| EXPORTED_FUNCTIONS=[...] | C functions accessible from JS |
| EXPORTED_RUNTIME_METHODS=[...] | Emscripten runtime helpers accessible from JS |
| ENVIRONMENT=web,worker | Target environments (affects code generation) |
| INITIAL_MEMORY=256MB | Starting linear memory size |
When adding dlopen support (MAIN_MODULE=1):
-l flags for C libraries. Library directories contain both.a (static) and .so (WASM side module) files. With MAIN_MODULE, PIC
mode makes the linker prefer .so. When wasm-ld encounters a .so
under --whole-archive, it crashes with SIGSEGV. Fix: use explicit .a
paths in .emcc-php-wasm-sources instead of -l flags.
MAIN_MODULE=1 has linker limitations. wasm-ld cannot handle--whole-archive + --experimental-pic on archives as large as
libphp.a. This is a fundamental limitation. Start with =1 (exports
all symbols), then consider =2 for optimization.
environment, Emscripten hardcodes booleans (ENVIRONMENT_IS_WEB = true).
With multiple, it generates runtime detection. Post-processing regex
patterns in the Dockerfile must handle both forms.
The Dockerfile uses sed / replace.sh to modify the compiled JS glue
file after Emscripten runs. Common patches:
_malloc binding when it's not auto-exposed: PHPLoader['malloc'] = wasmExports['malloc'];
// Injected right after assignWasmExports() in the glue file
memory.grow() corruption duringhandleSleep() (see debug-php-wasm-main-module skill for details)
ENVIRONMENT_IS_* substitution for multi-environment buildsWhen upgrading Emscripten, expect these categories of breakage:
setErrNo() removed — use HEAP32[___errno_location() >> 2] = code_malloc/_free no longer auto-exposed — add to EXPORTED_FUNCTIONSHEAPU8/HEAPU32 need explicit EXPORTED_RUNTIME_METHODS-Wincompatible-pointer-types becomes an error. Fix: correct thetypes, not the warning level.
#if PHP_MAJOR_VERSION >= 8 may be too broad(e.g. zend_file_handle.filename is const char * in 8.0 but
zend_string * in 8.1+)
fd_close) may gain JS intermediateframes, breaking JSPI suspension. Symptom: startup hangs silently.
Fix: remove from JSPI_IMPORTS/JSPI_EXPORTS.
exitRuntime() → __funcs_on_exit() may trigger JSPI suspension.Add to JSPI_EXPORTS and EXPORTED_FUNCTIONS.
php*.patch) for older versions as neededSide modules are PHP extensions (Xdebug, intl, GD, etc.) compiled as
WASM shared libraries loaded via dlopen.
The extension build needs a minimal PHP installation (for phpize and
headers). Key Emscripten-specific requirements:
| Requirement | Detail |
| -------------------------- | ----------------------------------------------------------------------------------------------------- |
| Inline assembly patches | HAVE_ASM_GOTO, ZEND_USE_ASM_ARITHMETIC, __GNUC__, __clang__ — same patches as main Dockerfile |
| --without-pcre-jit | SLJIT uses x86 assembly, unavailable in WASM |
| PHP 8.4 flag change | --disable-libxml became --without-libxml |
| Remove -lm from Makefile | Math library is in the main module |
| EMCC_FLAGS | -sSIDE_MODULE -D__x86_64__ -sWASM_BIGINT |
| wasm-opt path | /root/emsdk/upstream/bin/wasm-opt (not on PATH) |
When the side module uses custom renamed imports (e.g. -Drecv=wasm_recv),
you MUST pass -sASYNCIFY_IMPORTS=<custom_name>:
export EMCC_FLAGS="-sSIDE_MODULE -sASYNCIFY -sASYNCIFY_IMPORTS=wasm_recv"
Without this, Binaryen won't instrument call sites for those imports —
locals won't be saved/restored, causing table index is out of bounds
during Asyncify rewind.
Libtool refuses to create WASM shared libraries. Two workarounds:
archive_cmds — replace $CC withemcc $EMCC_FLAGS -shared --whole-archive <static archives> --no-whole-archive
em++, discovering all.o files recursively (find . -path '*/.libs/*.o')
When using approach 2, check subdirectories — C++ libraries often produce
.o files in nested paths that build scripts miss.
ICU .a archives and other pre-built artifacts committed to the repo may
not match current build flags. When MAIN_MODULE=1 requires PIC,
pre-built non-PIC archives cause R_WASM_MEMORY_ADDR_SLEB relocation
errors. Rebuild from source if flags changed.
Docker BuildKit caches aggressively. Before rebuilding:
# Remove the image to force a true rebuild
docker rmi php-wasm:latest
# docker builder prune alone is NOT sufficient — BuildKit reuses
# intermediate layers from existing images
# Also reset NX cache
node node_modules/.bin/nx reset
When the build produces .wasm files that don't work correctly:
# List exports and imports
wasm-objdump -x module.wasm
# Disassemble
wasm-objdump -d module.wasm
# Print WAT form (verify Asyncify instrumentation)
wasm-opt --print module.so
# From JavaScript — inspect a side module
node -e "
const fs = require('fs');
const mod = new WebAssembly.Module(fs.readFileSync('module.so'));
console.log('exports:', WebAssembly.Module.exports(mod).map(e => e.name));
console.log('imports:', WebAssembly.Module.imports(mod).map(i => i.name));
"
Cross-reference symbol lists with:
ASYNCIFY_ONLY function list (main module)EXPORTED_FUNCTIONS in the Emscripten build flagsSIDE_MODULE / MAIN_MODULE dynamic linking expectations| Situation | Action |
| ---------------------------------- | ------------------------------------------------------------- |
| Build fails with compiler error | Read the error, fix C/Makefile, retry |
| Build succeeds but WASM won't load | List imports — runtime is missing something |
| Build succeeds but runtime crashes | List exports + check Asyncify/JSPI function lists |
| Behavior is wrong but no error | Add printf to C code, rebuild, trace |
| Extension fails as SIDE_MODULE | Check dynamic linking flags, verify symbol visibility |
| Linker SIGSEGV with MAIN_MODULE | Switch -l flags to explicit .a paths |
| R_WASM_MEMORY_ADDR_SLEB error | Pre-built archive not compiled with PIC — rebuild from source |
| Don't know what a build step does | Read the Dockerfile/Makefile line by line |
Take wordpress/compile-php-wasm from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.