| name | wp-playground-php-wasm |
| description | WordPress Playground and @php-wasm runtime expert. Use when working with the PHP WebAssembly runtime, PHP instance lifecycle, php.run() execution model, file system operations (writeFile, readFileAsBuffer, mkdir, isDir), php.ini configuration via setPhpIniEntries(), request/response conversion, or debugging issues that originate in the upstream @php-wasm/web and @php-wasm/universal packages. |
| metadata | {"author":"moodle-playground","version":"1.0"} |
WordPress Playground & @php-wasm Runtime Expert
Role
You are an expert in WordPress Playground's PHP WebAssembly runtime โ the @php-wasm/web
and @php-wasm/universal packages that power the PHP execution layer. You understand the
PHP instance lifecycle, the Emscripten virtual filesystem, and the bridge between JavaScript
and PHP execution. You know where the upstream APIs behave unexpectedly and where our
compatibility layer (php-compat.js) adapts the WP Playground API for Moodle's needs.
When to activate
- Working with
src/runtime/php-loader.js (PHP instance creation)
- Working with
src/runtime/php-compat.js (API adapter layer)
- Debugging PHP execution issues (
php.run() behavior, exit codes, output capture)
- Investigating WASM-level crashes or memory issues
- Configuring PHP settings (
setPhpIniEntries())
- Working with the Emscripten MEMFS filesystem
- Debugging file I/O operations in the PHP runtime
- Investigating upstream issues in WordPress Playground
Upstream repository
Source: https://github.com/WordPress/wordpress-playground
Key packages we depend on:
@php-wasm/web (v3.1.12+) โ Browser-specific PHP runtime, WASM binary loading
@php-wasm/universal (v3.1.12+) โ Platform-agnostic PHP API, filesystem, ini config
PHP instance lifecycle
Creation
import { loadWebRuntime } from '@php-wasm/web';
const php = await loadWebRuntime('8.3', {
});
In our project, src/runtime/php-loader.js wraps this:
const rawPhp = await loadWebRuntime(phpVersion, emscriptenOptions);
setPhpIniEntries(rawPhp, iniEntries);
The raw PHP instance is then wrapped by php-compat.js into a compatibility layer
that maps WP Playground's API to the interface expected by bootstrap.js and php-worker.js.
Execution model
Each php.run() call is a complete PHP lifecycle:
- PHP globals are initialized fresh
$_SERVER, $_GET, $_POST, etc. are populated from the request object
- The PHP script executes to completion (or fatal error)
- All PHP state is destroyed (variables, open handles, PDO connections)
- Output (stdout) and headers are captured and returned
Critical implication: PHP state does NOT persist between php.run() calls. This is
why the SQLite database must be a file in MEMFS, not :memory: โ a :memory: DB would
be empty on the next request because the PDO connection is closed and reopened.
Request format
const response = await php.run({
scriptPath: '/www/moodle/admin/index.php',
method: 'GET',
headers: { 'Host': 'localhost', 'Cookie': 'MoodleSession=abc123' },
url: '/admin/index.php?redirect=0',
body: '',
});
The response object:
{
httpStatusCode: 200,
headers: { 'content-type': ['text/html; charset=utf-8'], ... },
text: '<html>...',
bytes: Uint8Array,
errors: '',
exitCode: 0,
}
CLI mode execution
For provisioning (blueprint steps), we use CLI mode:
const response = await php.run({
scriptPath: '/tmp/script.php',
});
The PHP script should define CLI_SCRIPT before requiring Moodle's config.php:
define('CLI_SCRIPT', true);
require('/www/moodle/config.php');
Filesystem API
The PHP instance exposes Emscripten's MEMFS through these methods:
| Method | Description |
|---|
php.writeFile(path, data) | Write string or Uint8Array to path |
php.readFileAsText(path) | Read file as UTF-8 string |
php.readFileAsBuffer(path) | Read file as Uint8Array |
php.mkdir(path) | Create directory (parent must exist) |
php.mkdirTree(path) | Create directory and all parents |
php.isDir(path) | Check if path is a directory |
php.fileExists(path) | Check if path exists (file or directory) |
php.unlink(path) | Delete a file |
php.listFiles(path) | List directory contents |
php.analyzePath(path) | Emscripten FS.analyzePath() โ returns { exists, object } |
Important: These operate on the raw PHP instance (php._php in our compat layer),
not on the compatibility wrapper. The compat wrapper (php-compat.js) exposes a subset
and adds error handling.
MEMFS characteristics:
- All data lives in JavaScript heap memory (RAM)
- No durability โ tab close = data loss
- No size limits beyond available heap memory
- File operations are synchronous and fast (no I/O wait)
- Permissions are emulated but not enforced
- Symlinks are supported but rarely used
php.ini configuration
Critical: WP Playground hardcodes the ini path to /internal/shared/php.ini. Writing
a separate php.ini file anywhere else has NO effect. All settings must go through:
import { setPhpIniEntries } from '@php-wasm/universal';
setPhpIniEntries(php, {
'date.timezone': 'UTC',
'memory_limit': '512M',
'max_execution_time': '0',
'display_errors': 'Off',
'session.save_path': '/tmp/moodle/sessions',
'upload_tmp_dir': '/tmp',
});
This must be called before any php.run() โ settings applied after execution has
started may not take effect for all directives.
Our project applies ini settings in src/runtime/php-loader.js during runtime creation,
with timezone overrides applied later in src/runtime/bootstrap.js after blueprint parsing.
The compatibility layer (php-compat.js)
Our src/runtime/php-compat.js wraps the raw WP Playground PHP instance to provide:
- Request conversion: Browser
Request โ PHP request object format
- Response conversion: PHP response โ browser
Response
$_SERVER population: SCRIPT_NAME, PHP_SELF, REQUEST_URI, PATH_INFO,
SCRIPT_FILENAME, DOCUMENT_ROOT, HTTP_* headers
- Base path injection: Prepends URL base path (e.g.,
/moodle-playground) to
SCRIPT_NAME and PHP_SELF for correct Moodle URL construction
- PATH_INFO resolution: Splits URLs like
/theme/styles.php/boost/123/all into
script path (/theme/styles.php) and PATH_INFO (/boost/123/all)
- MIME type detection: Returns correct Content-Type for static files
analyzePath emulation: Wraps Emscripten's FS.analyzePath() for path checking
PATH_INFO handling (critical)
Moodle uses PATH_INFO extensively for resource URLs:
/theme/styles.php/boost/1234567890/all โ CSS delivery
/theme/javascript.php/1234567890/boost/head โ JS delivery
/pluginfile.php/1/mod_resource/content/0/file.pdf โ File serving
resolveScriptPath() in php-compat.js must:
- Walk the URL path segments
- Find the first segment ending in
.php
- Split into script path (before and including
.php) and PATH_INFO (after)
Without this, isPhpScript() returns false (URL doesn't end in .php) โ 404.
Known issues and upstream bugs
File descriptor exhaustion
Memory limits
- WASM linear memory has a growth limit (~2-4 GB depending on browser)
- Large Moodle operations (file uploads, backup/restore) can hit this
- OOM manifests as
RuntimeError: memory access out of bounds
- No way to free WASM memory โ must restart the runtime
Extension availability
Available in PHP 8.3 WASM: sqlite3, pdo_sqlite, dom, simplexml, xml,
mbstring, openssl, intl, iconv, zlib, zip, phar, curl, gd,
fileinfo, xmlreader, xmlwriter.
NOT available: sodium (despite being listed in some docs). The OpenSSL fallback
patch in patches/shared/lib/classes/encryption.php handles encryption needs.
Session handling
- Sessions use file-based storage in MEMFS (
/tmp/moodle/sessions/)
- Session data persists across
php.run() calls (files survive in MEMFS)
- Session IDs are generated by PHP and tracked via
MoodleSession cookie
- After crash recovery, sessions are invalidated โ a new admin session must be created
Debugging tips
- Check
response.errors โ stderr output often contains PHP warnings/notices
- Check
response.exitCode โ non-zero means PHP script failed
- Enable PHP display_errors โ set
display_errors=On in ini entries for debugging
- Use
?debug=true โ forces DEBUG_DEVELOPER and display_errors=1 at boot
- Read upstream source โ when
php-compat.js behavior is unclear, check the
@php-wasm/universal source at node_modules/@php-wasm/universal/
- MEMFS inspection โ use
php.listFiles() and php.readFileAsText() to inspect
the virtual filesystem during debugging
Fragile Areas (from AGENTS.md)
These areas have repeatedly caused regressions and require extra care:
php.ini configuration
- WP Playground hardcodes
/internal/shared/php.ini via PHP_INI_PATH in @php-wasm/universal
- Writing a separate php.ini file (e.g.,
/www/php.ini) has NO effect โ PHP never reads it
- All php.ini settings must be applied via
setPhpIniEntries() from @php-wasm/universal
- Settings are applied in
src/runtime/php-loader.js during runtime creation
- Blueprint timezone overrides are applied in
src/runtime/bootstrap.js after provisioning
Outbound PHP networking
tcpOverFetch must stay enabled in src/runtime/php-loader.js; disabling it removes
the generated CA and breaks all outbound HTTP(S) from PHP.
openssl.cafile and curl.cainfo must point to /internal/shared/playground-ca.pem
whenever tcpOverFetch is active.
playground.config.json should use phpCorsProxyUrl for PHP networking fallback and
addonProxyUrl for browser-side ZIP downloads; the old generic proxyUrl alias should
not be reintroduced.
MOODLE_PLAYGROUND_PROXY_URL must stay scope-aware (/playground/<scope>/<runtime>/...);
plugins that choose the same-origin proxy path must not derive proxy URLs from
$CFG->wwwroot alone.
- The Service Worker endpoint
__playground_proxy__ must preserve the incoming query
string and forward it to the configured external proxy unchanged.
- The supported and tested paths for GitHub feeds/assets from PHP are now both:
direct HTTPS through
phpCorsProxyUrl and the same-origin proxy endpoint.
php-compat.js CGI variables
- CGI environment variables such as
HTTP_USER_AGENT, SCRIPT_NAME, and SCRIPT_FILENAME are critical
- The Request-to-PHPRequest conversion must preserve headers, method, and body
- The PHPResponse-to-Response conversion must preserve status codes and headers
- URL base path in
$_SERVER: SCRIPT_NAME, PHP_SELF, and REQUEST_URI must include
the URL base path (e.g., /moodle-playground on GitHub Pages). Moodle's
setup_get_remote_url() in lib/setuplib.php constructs $FULLME/$FULLSCRIPT by
extracting only the scheme+host from $CFG->wwwroot and combining it with
$_SERVER['SCRIPT_NAME']. If SCRIPT_NAME lacks the base path, all redirect URLs lose
the subpath, causing infinite redirect loops on subpath deployments.
Checklist for php-wasm-touching changes