Playground and Blueprints
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WordPress Playground runs a complete WordPress site without a web server or a database server: PHP is compiled to
WebAssembly and the database is SQLite. A Blueprint, a JSON file, describes what such a site contains. The same
Blueprint drives the site in a browser tab, in an embedded <iframe>, in the Playground CLI on a developer machine or
in CI, and behind the Preview buttons of the plugin directory and of GitHub pull requests. Plugin authors use it for
demos and reproducible bug reports, theme designers for disposable design sites, reviewers for one-click previews, and
teachers for workshops where nobody installs anything.
The daily local use of Playground (the browser site, start and server, migrating from wp-now) is introduced in
Local Development Environments. This page
covers the rest: how it works, the full Blueprint format, the Query and JavaScript APIs, the CLI commands beyond
start, plugin and pull request previews, and the limits.
The page was written against WordPress 7.1.3 (examples tested; 7.1.4, released on 2026-10-28, is the current security
release and the one to run in production), PHP 7.4 minimum and 8.3+ recommended, and the Playground CLI
@wp-playground/cli and client @wp-playground/client 3.1.61, and checked on 2026-10-28. WordPress 7.2 is in beta and
due in December 2026. Every command below ran with the CLI on Node.js 24.
How Playground works
| Part | What it does |
|---|---|
PHP.wasm |
PHP 7.4 to 8.5 compiled to WebAssembly. Each version is a separate |
Virtual file system |
WordPress lives under |
SQLite |
The SQLite Database Integration plugin is loaded as a drop-in and translates every MySQL query into SQLite. The database file is |
Request routing |
In the browser a service worker intercepts the requests of the |
Networking |
WebAssembly can only call JavaScript. In the browser |
The ways to run it:
| Runtime | Entry point | Typical use |
|---|---|---|
Browser app |
|
Trying a plugin or theme, sharing a reproducible set-up as a link, previews |
Embedded |
|
Interactive documentation, tutorials, product demos |
Playground CLI |
|
Local development without Docker, smoke tests and snapshots in CI |
wp-env |
|
Projects already on wp-env that cannot run Docker |
Blueprints
A Blueprint is a JSON object with a few top-level properties and an ordered list of steps. Pointing $schema at
https://playground.wordpress.net/blueprint-schema.json gives editors completion and validation.
| Property | Meaning |
|---|---|
|
JSON Schema for the Blueprint format; ignored at run time. |
|
|
|
Relative URL opened once the steps have run, for example |
|
|
|
|
|
|
|
Shorthand for |
|
Shorthand: directory slugs or resources to install and activate, for example |
|
Shorthand for |
|
Shorthand: |
|
Ordered list of step objects, each with a |
|
Deprecated and ignored; the schema says it can be removed. Older Blueprints, including the plugin directory’s examples, still carry |
Shorthands are expanded before the steps array, so plugins, login, siteOptions and constants always run
first. When order matters (a constant that must exist before a plugin is activated, a login as a user created by a
step), write explicit steps instead. A step number in an error message counts the expanded list.
In preferredVersions.wp, 7.1 means the newest 7.1 point release: on the check date it resolved to 7.1.4. A
Blueprint that must reproduce a bug in one release names it exactly: "wp": "7.1.3" booted 7.1.3.
Steps
| Step | Arguments and remarks |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Imports the active theme’s starter content. |
|
|
|
Converts the site into a network. |
|
|
|
File operations inside the virtual file system. |
|
|
Resources
Steps that need a file take a resource reference rather than a path:
resource |
Fields | Use |
|---|---|---|
|
|
A built zip on a public URL that downloads without cookies or login and sends CORS headers (a release asset, an S3 object). An HTML error page or a login page fails as "not a valid zip". |
|
|
Directory plugins and themes. The slug is separate: |
|
|
Source straight from a repository (sparse checkout through a CORS proxy). A branch or tag needs |
|
|
A file already in the virtual file system, for example in a folder mounted by the CLI. |
|
|
Inline file content. |
|
|
A file shipped next to |
The Lumen Press demo Blueprint
Lumen Press keeps a Blueprint that builds a demo of the catalogue: the lumen theme and the lumen-catalog plugin from
locally built zips, a site name, pretty permalinks and three seeded books. Both zips have a root folder named after the
slug (lumen-catalog/lumen-catalog.php, lumen/style.css), and the plugin zip includes vendor/ from Composer,
because Playground installs exactly what is in the zip and runs no build. The release zip is produced as described in
Publishing to the Plugin Directory;
the steps below assume both zips are in a dist/ folder.
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"meta": {
"title": "Lumen Press catalogue demo",
"author": "lumenpress"
},
"landingPage": "/wp-admin/edit.php?post_type=lp_book",
"preferredVersions": { "php": "8.4", "wp": "7.1" },
"features": { "networking": false },
"login": true,
"steps": [
{
"step": "installTheme",
"themeData": { "resource": "vfs", "path": "/lumen-dist/lumen.zip" },
"options": { "activate": true }
},
{
"step": "installPlugin",
"pluginData": { "resource": "vfs", "path": "/lumen-dist/lumen-catalog.zip" },
"options": { "activate": true }
},
{
"step": "setSiteOptions",
"options": {
"blogname": "Lumen Press (Playground)",
"permalink_structure": "/%postname%/"
}
},
{
"step": "wp-cli",
"command": "wp post create --post_type=lp_book --post_status=publish --post_title='The Lighthouse Ledger' --meta_input='{\"lp_isbn\":\"978-1-23456-789-7\"}'"
},
{
"step": "runPHP",
"code": "<?php require_once '/wordpress/wp-load.php'; foreach ( array( 'The Glass Orchard' => '978-1-23456-790-3', 'The Quiet Lighthouse' => '978-1-23456-791-0' ) as $title => $isbn ) { wp_insert_post( array( 'post_type' => 'lp_book', 'post_status' => 'publish', 'post_title' => $title, 'meta_input' => array( 'lp_isbn' => $isbn ) ) ); }"
}
]
}
The two seeding styles are deliberate: a wp-cli step suits one simple command, a runPHP step suits anything with
logic. "networking": false keeps the demo from calling out (no update checks, no distributor API), which also
makes it start faster. lp_isbn must be registered with show_in_rest for it to appear in REST responses, and
lp_book needs 'show_in_rest' => true for the /wp/v2/lp_book route to exist (see
Custom Post Types and Taxonomies).
The CLI mounts dist/ at /lumen-dist, which is where the vfs resources look:
npx @wp-playground/cli@3.1.61 server \
--port=9400 --php=8.4 --wp=7.1 \
--mount=./dist:/lumen-dist \
--blueprint=./blueprint.json
WordPress Playground CLI
PHP 8.4 WordPress 7.1
Extensions intl, redis, memcached
Mount ./dist → /lumen-dist
Blueprint ./blueprint.json
Ready! WordPress is running on http://127.0.0.1:9400 (6 workers)
The server answers the first request of a new client with a 302 to the same URL; with "login": true that
response also sets the admin cookies and playground_auto_login_already_happened=1. A browser follows it
transparently, while plain curl gets an empty body. Sending that cookie, or -L with a cookie jar
(-L -c jar -b jar), avoids the problem:
curl -s -b 'playground_auto_login_already_happened=1' \
'http://127.0.0.1:9400/wp-json/wp/v2/lp_book?_fields=id,title.rendered,meta.lp_isbn,link&orderby=title&order=asc'
Source: REST API Handbook: Posts
[
{ "id": 5, "link": "http://127.0.0.1:9400/books/the-glass-orchard/", "title": { "rendered": "The Glass Orchard" }, "meta": { "lp_isbn": "978-1-23456-790-3" } },
{ "id": 4, "link": "http://127.0.0.1:9400/books/the-lighthouse-ledger/", "title": { "rendered": "The Lighthouse Ledger" }, "meta": { "lp_isbn": "978-1-23456-789-7" } },
{ "id": 6, "link": "http://127.0.0.1:9400/books/the-quiet-lighthouse/", "title": { "rendered": "The Quiet Lighthouse" }, "meta": { "lp_isbn": "978-1-23456-791-0" } }
]
The front page reported WordPress 7.1.4 in its generator tag, /books/ answered 200 and the theme’s assets came
from themes/lumen/. Because only dist/ is mounted, the site itself lives in a temporary directory and is gone when
the server stops; that is what a demo or a test wants.
Blueprint bundles
A bundle is a blueprint.json plus the files it references, as a directory, a zip (with blueprint.json at the
root or one folder deep, so a macOS "Compress" archive works), a folder in a Git repository, or a remote URL. Steps
reference the files with { "resource": "bundled", "path": "/lumen-catalog.zip" }. The browser app opens a zipped
bundle with ?blueprint-url=https://…/bundle.zip. The CLI refuses to read files next to a local Blueprint unless
told to:
# bundle/ holds blueprint.json, lumen.zip and lumen-catalog.zip
npx @wp-playground/cli@3.1.61 run-blueprint --php=8.4 --wp=7.1 \
--blueprint=./bundle/blueprint.json --blueprint-may-read-adjacent-files
Without the flag the run stopped with "Playground restricts access to local resources by default as a security measure"; with it, it finished with exit code 0.
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
A valid property in the wrong object: |
|
The URL returned an HTML page (a GitHub file viewer, a login page, an expired CI artifact) or lacks CORS headers. |
|
A PHP fatal error, often activation of a plugin whose |
"Undefined constant ABSPATH" |
A |
"Current PHP version (…) does not meet minimum requirements" |
|
Plugin missing after install |
The zip has files at its root instead of in a |
A |
|
WP-CLI "Error establishing a database connection" with |
The SQLite integration is loaded from Playground’s internal files, not from the mounted site; install it into the mounted |
The Query API
The browser app reads its configuration from the URL, which makes a Playground a link:
| Parameter | Default | Meaning |
|---|---|---|
|
URL of a Blueprint JSON file or bundle zip (CORS required). |
|
|
Directory slug to install; repeat for several. Installing logs the user in. |
|
|
|
Versions, with the same values as |
|
|
First page to open. |
|
|
|
|
|
Log in as the administrator. |
|
|
|
|
|
|
|
URL of a site zip or a WXR file to import. |
|
|
|
Locale; needs networking for the translations. |
|
|
|
|
Run a pull request or branch of wordpress-develop or Gutenberg. |
|
|
Do not boot until the visitor clicks Run. |
A link to the hosted demo Blueprint, and an embed of a directory plugin in the Site Editor:
<a href="https://playground.wordpress.net/?blueprint-url=https://downloads.lumenpress.example/playground/bundle.zip">
Try the Lumen Press catalogue
</a>
<iframe
src="https://playground.wordpress.net/?plugin=query-monitor&url=/wp-admin/site-editor.php&mode=seamless&storage=temp"
title="WordPress Playground" width="1000" height="640" loading="lazy"></iframe>
downloads.lumenpress.example must send Access-Control-Allow-Origin (at least for
https://playground.wordpress.net) and serve the files without authentication. Query parameters are visible to
anyone with the link and end up in server logs: they must never carry a password, an application password or an API
key.
The JavaScript API
@wp-playground/client controls a Playground running in an <iframe> of an own page. startPlaygroundWeb() loads
remote.html (the bare runtime, without the browser chrome of index.html) into the frame, runs the Blueprint and
returns a client. A JSON Blueprint is accepted only at start; afterwards the client’s methods or the Blueprint
functions (installPlugin( client, ... ), login( client, ... )) change the running site.
<iframe id="lumen-demo" title="Lumen Press demo" style="width: 100%; height: 640px; border: 0"></iframe>
<script type="module">
import { startPlaygroundWeb } from 'https://unpkg.com/@wp-playground/client@3.1.61/index.js';
const client = await startPlaygroundWeb( {
iframe: document.getElementById( 'lumen-demo' ),
remoteUrl: 'https://playground.wordpress.net/remote.html',
blueprint: {
preferredVersions: { php: '8.4', wp: '7.1' },
login: true,
steps: [
{
step: 'installPlugin',
pluginData: { resource: 'url', url: 'https://downloads.lumenpress.example/playground/lumen-catalog.zip' },
options: { activate: true },
},
],
},
} );
await client.isReady();
const result = await client.run( {
code: "<?php require '/wordpress/wp-load.php'; echo (int) wp_count_posts( 'lp_book' )->publish;",
} );
console.log( 'Published books:', result.text );
await client.goTo( '/books/' );
</script>
| Method | Purpose |
|---|---|
|
Resolves when WordPress has booted and the Blueprint has run. |
|
Runs PHP once (snippet or file mode), resetting PHP state each time; returns |
|
Sends an HTTP request through WordPress, as a browser would. |
|
Navigates the frame. |
|
Virtual file system. |
|
Changes |
From npm the same function is import { startPlaygroundWeb } from '@wp-playground/client' after
npm install @wp-playground/client@3.1.61. While debugging, both index.html and remote.html expose the client as
window.playground in the browser console. Opting in to workerMode: 'shared' (with a scope) shares one runtime
between tabs, but requires serving remote.html from the own origin.
The Playground CLI
@wp-playground/cli needs Node.js 20.18 or later and no Docker. Its commands:
| Command | Use |
|---|---|
|
Detects whether the current directory is a plugin, theme, |
|
Everything explicit: mounts, Blueprint, versions, port. Without a mount of |
|
Applies a Blueprint and exits without serving, which turns a Blueprint into a smoke test: a failing step exits non-zero. |
|
Applies a Blueprint and writes the resulting site as a zip, importable with |
|
Runs a PHP script with Playground’s PHP. |
Option (for server) |
Meaning |
|---|---|
|
Versions; PHP defaults to 8.3, WordPress to |
|
Listening port (default 9400) and the URL WordPress uses for links. |
|
Mount a host folder after installation; repeatable. |
|
Mount before installation, for example a full checkout at |
|
The |
|
A Blueprint JSON file, bundle directory, bundle zip or URL. |
|
Consent for |
|
Log in as the administrator (default false for |
|
Do not install the SQLite integration, for a site that connects to MySQL itself. |
|
Xdebug, and its VS Code or PhpStorm configuration. |
|
Installs phpMyAdmin over the SQLite database. |
|
Request workers; defaults to min(6, CPUs − 1). Parallel end-to-end suites may need more. |
|
Follow symlinks out of mounted folders; this exposes files outside the mount. |
|
Show the temporary directory and the PHP error log on boot failures. |
npx @wp-playground/cli@3.1.61 --help and server --help list the full set. The CLI logs every visitor in as the
administrator when login is on and serves http://127.0.0.1:<port> with debugging-friendly defaults; it is not a way to
publish a site. The npm package
@wp-now/wp-now is deprecated and its npm entry points to the Playground CLI; wp-now start maps to start with the
same --wp, --php, --blueprint, --skip-browser and --reset options.
Plugin previews on WordPress.org
The plugin directory shows a Preview button next to Download when two conditions hold: a valid
assets/blueprints/blueprint.json is committed to the plugin’s SVN assets folder, and a committer has set the
preview to public on the plugin’s Advanced tab. With only the Blueprint present, committers see a Test Preview
button to check the result first. A plugin without a Blueprint gets a notice with a Test link that uses a generated
Blueprint and a link to download that file as a starting point. Only one Blueprint per plugin is supported.
The directory edition of lumen-catalog (without Update URI, see
Publishing to the Plugin Directory) keeps its preview
Blueprint in .wordpress-org/blueprints/blueprint.json, which the deploy action copies to assets/:
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"landingPage": "/books/",
"preferredVersions": { "php": "8.3", "wp": "latest" },
"steps": [
{ "step": "login", "username": "admin", "password": "password" },
{
"step": "installPlugin",
"pluginData": { "resource": "wordpress.org/plugins", "slug": "lumen-catalog" },
"options": { "activate": true }
},
{ "step": "setSiteOptions", "options": { "permalink_structure": "/%postname%/" } },
{
"step": "runPHP",
"code": "<?php require_once '/wordpress/wp-load.php'; foreach ( array( 'The Lighthouse Ledger' => '978-1-23456-789-7', 'The Glass Orchard' => '978-1-23456-790-3' ) as $title => $isbn ) { wp_insert_post( array( 'post_type' => 'lp_book', 'post_status' => 'publish', 'post_title' => $title, 'meta_input' => array( 'lp_isbn' => $isbn ) ) ); }"
}
]
}
The handbook’s own examples still use pluginZipFile, themeZipFile and phpExtensionBundles; current Playground
accepts the first two as deprecated aliases and ignores the third, so new Blueprints use pluginData, themeData and
nothing else. A preview is a disposable site: what a visitor does there never reaches the plugin author, and demo
content should be fictitious. Seeding through runPHP rather than importWxr keeps the demo free of a separately
hosted WXR file.
Pull request previews in GitHub Actions
WordPress/action-wp-playground-pr-preview adds a Preview button to the description (or a comment) of each pull
request, opening Playground with the plugin or theme from that pull request. Two set-ups exist:
-
No build step: one workflow on
pull_requestwithplugin-path: .(ortheme-path); Playground fetches the branch throughgit:directory. Only valid when the committed files are exactly what WordPress runs. -
With a build step: a reusable
preview-build.ymlworkflow runs the build with read-only permissions and uploads zips; apreview-publish.ymlworkflow, started byworkflow_run, publishes them as assets of a publicci-artifactsprerelease and writes the button. This is also the safe set-up for pull requests from forks.
lumen-catalog needs composer install (for vendor/) and wp-scripts build (for build/blocks/), so it uses the
second set-up. .github/workflows/pr-preview-build.yml:
name: PR Preview - Build
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
build:
uses: WordPress/action-wp-playground-pr-preview/.github/workflows/preview-build.yml@v4.0.0
with:
artifacts: lumen-catalog=lumen-catalog.zip
node-version: '24'
php-version: '8.3'
build-command: |
set -euo pipefail
composer install --no-dev --optimize-autoloader --no-interaction
npm ci
npm run build
npm run plugin-zip
npm run plugin-zip writes lumen-catalog.zip with a lumen-catalog/ root folder, the shape Playground expects.
.github/workflows/pr-preview-publish.yml:
name: PR Preview - Publish
on:
workflow_run:
workflows: ['PR Preview - Build']
types: [completed]
permissions:
contents: write
pull-requests: write
jobs:
publish:
permissions:
contents: write
pull-requests: write
uses: WordPress/action-wp-playground-pr-preview/.github/workflows/preview-publish.yml@v4.0.0
with:
blueprint: |
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"landingPage": "/wp-admin/edit.php?post_type=lp_book",
"preferredVersions": { "php": "8.4", "wp": "7.1" },
"steps": [
{ "step": "login", "username": "admin", "password": "password" },
{
"step": "installPlugin",
"pluginData": { "resource": "url", "url": "{{ARTIFACT_URL:lumen-catalog}}" },
"options": { "activate": true }
}
]
}
{{ARTIFACT_URL:lumen-catalog}} is replaced by the public URL of the uploaded zip; the name matches the left side
of artifacts. The workflows must exist on the default branch before they run for pull requests, and both must use
the same action version. Rules that matter for security:
-
Never switch to
pull_request_targetto get write permissions: it would run untrusted pull request code with them. The two-workflow split exists precisely so that the code runs read-only and only the trusted publish workflow writes. -
The zips become public release assets. A private repository’s code would be published that way, and Playground cannot fetch private URLs anyway; private repositories need another preview host.
-
secrets.GITHUB_TOKENis provided by GitHub; no secret is created, and nothing secret goes into the Blueprint.
A preview that opens but shows "PHP.run() failed with exit code 255" usually means a missing vendor/, the case
the build step is there for. Previews complement, not replace, the test jobs in
Testing and the pipeline in Deployment and CI/CD.
Limitations
| Area | Limitation |
|---|---|
Database |
SQLite through a translation layer, not MySQL: plugins that send unusual SQL, use MySQL-only functions or connect to MySQL themselves may fail. Version 2.0 of the integration passes about 99% of the WordPress unit tests; a passing Playground run is not proof of MySQL compatibility. Production remains MySQL or MariaDB (see Requirements and Hosting). |
Network |
Networking is on by default in current versions, but in the browser every request is a |
Persistence |
Browser sites are autosaved, at most five, and autosaves are recovery points: clearing site data or private browsing loses them. |
WP-CLI |
The |
Performance |
5 to 10 s for a bare site, 30 to 60 s with a large plugin, slower on phones. Not a load-testing target. |
Extensions |
A fixed set of PHP extensions; others need a PHP.wasm extension manifest ( |
Frame |
The site runs in an |
Security model |
Everyone is logged in as an administrator of a throwaway site. Never put real user data, production dumps or credentials into a Blueprint or a public preview. |
Corrections and complements to older books
None of the three books in the bibliography covers Playground. Ultimate WordPress Handbook and All-in-One For
Dummies set up local sites with desktop stacks (Local, XAMPP, MAMP), which still work but leave nothing reproducible
to share: a Blueprint in the repository gives every reviewer, tester and student the same site with one link or one
command. Pro WordPress registers custom post types without show_in_rest; such a type has no /wp/v2/ route, so
the REST check used above, the block editor and every headless use of Playground fail for it. Tutorials that still use
wp-now, pluginZipFile or phpExtensionBundles predate the current CLI and schema; the replacements are listed
above and in Where Older Books and Tutorials Are
Outdated.