| name | compile-php-wasm |
| description | 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. |
Compiling 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
Quick Reference
```bash
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
npx 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 |
## Main Module Compilation
### Emscripten flags that matter
| 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 |
### MAIN_MODULE for dynamic extensions
When adding `dlopen` support (`MAIN_MODULE=1`):
- **Never use `-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=web,worker changes code generation.** With a single
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.
### Post-link Dockerfile patches
The Dockerfile uses `sed` / `replace.sh` to modify the compiled JS glue
file after Emscripten runs. Common patches:
- **Inject `_malloc` binding** when it's not auto-exposed:
```js
PHPLoader['malloc'] = wasmExports['malloc'];
// Injected right after assignWasmExports() in the glue file
- Cache Asyncify buffers to prevent
memory.grow() corruption during
handleSleep() (see debug-php-wasm-main-module skill for details)
- Guard
ENVIRONMENT_IS_* substitution for multi-environment builds
Emscripten version upgrade checklist
When upgrading Emscripten, expect these categories of breakage:
-
Removed/renamed APIs:
setErrNo() removed — use HEAP32[___errno_location() >> 2] = code
_malloc/_free no longer auto-exposed — add to EXPORTED_FUNCTIONS
HEAPU8/HEAPU32 need explicit EXPORTED_RUNTIME_METHODS
-
Stricter Clang compiler:
-Wincompatible-pointer-types becomes an error. Fix: correct the
types, not the warning level.
- PHP version checks like
#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+)
-
JSPI behavioral changes:
- WASI syscall wrappers (e.g.
fd_close) may gain JS intermediate
frames, 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.
-
Debugging approach:
- Build PHP 8.4 first (fewest compatibility issues)
- Fix build errors, then run JSPI tests
- Once 8.4 passes, test 8.0 and 7.4 for version-specific issues
- Patch PHP source files (
php*.patch) for older versions as needed
Side Module Compilation
Side modules are PHP extensions (Xdebug, intl, GD, etc.) compiled as
WASM shared libraries loaded via dlopen.
Build requirements
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) |
Asyncify side modules
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 issues
Libtool refuses to create WASM shared libraries. Two workarounds:
- Patch libtool's
archive_cmds — replace $CC with
emcc $EMCC_FLAGS -shared --whole-archive <static archives> --no-whole-archive
- Bypass libtool entirely — manually link with
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.
Pre-compiled artifacts
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.
Cache Busting
Docker BuildKit caches aggressively. Before rebuilding:
docker rmi php-wasm:latest
node node_modules/.bin/nx reset
WASM Binary Inspection
When the build produces .wasm files that don't work correctly:
wasm-objdump -x module.wasm
wasm-objdump -d module.wasm
wasm-opt --print module.so
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:
- The
ASYNCIFY_ONLY function list (main module)
EXPORTED_FUNCTIONS in the Emscripten build flags
SIDE_MODULE / MAIN_MODULE dynamic linking expectations
Diagnostic Cheat Sheet
| 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 |