Local Development Environments

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.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), @wordpress/env (wp-env) 12.0.0, which needs Node.js 22.13 or later, the Playground CLI @wp-playground/cli 3.1.61, WP-CLI 2.12.0 and the official Docker images wordpress:7.1.3-php8.4-apache, wordpress:cli-2.12.0-php8.4 and mariadb:11.8. WordPress needs PHP 7.4 at least and PHP 8.3 or later is recommended, so every environment below runs PHP 8.4. WordPress 7.2 is in beta and planned for 2026-12-08. It was checked on 2026-10-28.

A local environment is a complete WordPress site (PHP, a web server, a database and the WordPress files) running on a developer’s machine. Theme, plugin and block developers write and test code in it; site owners and administrators try updates, plugins and imports in it without touching the live site. This page runs the Lumen Press project (the lumen-catalog plugin, the lumen block theme and a must-use plugin) in three ways and then shows how the local copy fits with staging and production.

Local, staging and production

A professional WordPress project has at least three copies of the site, each with its own database, URL and WP_ENVIRONMENT_TYPE. Code moves up the chain through version control; content moves down as database exports. A lower environment’s database is never pushed over production, which keeps receiving comments, orders, users and edits that the copies do not have.

flowchart LR A["Local
wp-env, Compose or Playground"] -- "commit and push code" --> B["Git repository"] B --> C["CI: lint, tests, build"] C -- "deploy build" --> D["Staging
noindex, mail suppressed"] D -- "approved release" --> E["Production
lumenpress.example"] E -. "database export, sanitised" .-> D D -. "database and uploads copy" .-> A

Figure: code (solid arrows) moves from the local machine to production through Git, CI and staging; content (dotted arrows) is copied down from production.

  • Local (local): writing code, running tests, trying plugins and updates; WP_DEBUG and SCRIPT_DEBUG on, errors displayed, no real mail.

  • Development (development, optional): a shared integration server; this value also turns WP_DEBUG on when it is not defined.

  • Staging (staging): a recent copy of production on the production stack where releases are approved; errors logged but not displayed, noindex, password or IP protection, mail suppressed.

  • Production (production, the default): WP_DEBUG_DISPLAY off, real mail, caches and backups on.

Choosing a tool

The Block Editor Handbook recommends wp-env, maintained by the WordPress project, and the Playground CLI for quick tests. The Advanced Administration Handbook lists many more options. The common ones:

  • wp-env (@wordpress/env): needs Node.js 22.13+ and Docker, runs MariaDB. Best for plugin, theme and block development; ships the WordPress PHPUnit test files; used by Gutenberg and by Lumen Press.

  • Docker Compose with the official images: needs Docker. Full control of PHP, web server and database (MariaDB or MySQL); closest to a containerised production stack.

  • Playground CLI (@wp-playground/cli): needs Node.js 20.18+, no Docker, uses SQLite. Very fast throwaway sites, bug reproduction, Blueprint-driven demos, CI smoke tests.

  • Playground in the browser: needs nothing but a browser. Trying a plugin, sharing a reproducible set-up as a Blueprint, teaching.

  • Desktop applications and other tools: WordPress Studio, Local, MAMP, XAMPP, DDEV, Lando, VVV (see Desktop applications).

The npm package @wp-now/wp-now is deprecated; its npm entry points to the Playground CLI, which has an equivalent for every wp-now start option (see The Playground CLI).

All the set-ups below run the same lumen-press repository: lumen-catalog/ (the plugin), themes/lumen/ (the block theme), mu-plugins/lumen-environment.php, content/ (import fixtures), and the committed configuration files .wp-env.json, compose.yaml, .env.example, blueprint.json and package.json. .wp-env.override.json and .env are ignored by Git.

wp-env

wp-env is a Node.js command that writes a Docker Compose configuration and runs it: a wordpress container (Apache and PHP), a cli container (WP-CLI, Composer and PHPUnit) and a mysql container running MariaDB. It mounts the project into the install and activates it. Generated files live in a per-project directory under ~/.wp-env (~/wp-env on Linux), or under WP_ENV_HOME. Install it as a pinned development dependency, so every developer and CI run the same version:

npm install --save-dev --save-exact @wordpress/env@12.0.0   # adds "@wordpress/env": "12.0.0" to package.json
npx wp-env --version

Flags passed through npm run need an extra double dash (npm run env:start -- --xdebug), otherwise npm keeps them. npx wp-env <command> runs the local copy directly.

The .wp-env.json file

{
	"$schema": "https://schemas.wp.org/trunk/wp-env.json",
	"core": "https://wordpress.org/wordpress-7.1.3.zip",
	"phpVersion": "8.4",
	"mariadbVersion": "11.8",
	"testsEnvironment": false,
	"port": 8888,
	"plugins": [ "./lumen-catalog" ],
	"themes": [ "./themes/lumen" ],
	"mappings": {
		"wp-content/mu-plugins": "./mu-plugins",
		"wp-content/uploads/lumen-fixtures": "./content"
	},
	"config": {
		"WP_DEBUG_LOG": true,
		"WP_DEBUG_DISPLAY": true
	},
	"lifecycleScripts": {
		"afterStart": "npx wp-env run cli wp theme activate lumen && npx wp-env run cli wp rewrite structure /%postname%/ --hard"
	}
}
  • core: null (latest release), a GitHub reference (WordPress/WordPress#master), a local path such as ../wordpress-develop/build, or a ZIP URL. Pinning a release ZIP keeps every machine on the same version. The examples ran on 7.1.3; since 2026-10-28 production runs the 7.1.4 security release, so the pin is moved to https://wordpress.org/wordpress-7.1.4.zip to keep local and production on the same version.

  • phpVersion, mariadbVersion: the latter accepts lts (the default), latest or a number. MariaDB cannot start on data written by a newer version; lowering it requires wp-env cleanup first.

  • plugins are installed and activated; themes are only installed, hence the afterStart activation. Local paths, GitHub and SSH repositories and ZIP URLs are accepted.

  • mappings: mounts at any path without activation (must-use plugins, fixtures, a test plugin, an .htaccess with php_value limits).

  • config: wp-config.php constants merged with the defaults; null removes a default.

  • port (8888, overridden by WP_ENV_PORT), mysqlPort (a random host port unless set; wp-env status shows it), autoPort, phpmyadmin, phpmyadminPort, multisite.

  • lifecycleScripts: host commands run after start, reset, cleanup and destroy. They run on every start, so they must be idempotent.

  • testsEnvironment, testsPort and env are deprecated. Without "testsEnvironment": false, wp-env 12 still starts a second site on port 8889 and prints a warning; a second configuration file with --config replaces it.

wp-env defines WP_DEBUG and SCRIPT_DEBUG (true), WP_ENVIRONMENT_TYPE (local), FS_METHOD (direct, so plugins install without FTP credentials), WP_PHP_BINARY, the WP_TESTS_ constants, and WP_SITEURL/WP_HOME set to http://localhost:<port>. The administrator is admin/password and the database user root/password; these well-known credentials are acceptable only because the site listens on localhost.

.wp-env.override.json, ignored by Git, wins over .wp-env.json. Only config and mappings are merged; any other key replaces the whole value, so a personal plugins list must repeat ./lumen-catalog.

wp-env start --config=.wp-env.test.json starts an isolated environment (own containers and database) from a second file, with its override .wp-env.test.override.json; every command meant for it needs the same --config.

Everyday commands

npx wp-env start                       # create or restart the site on http://localhost:8888
npx wp-env start --update              # re-download sources, re-apply config (content is kept)
npx wp-env start --xdebug              # Xdebug "debug" mode on port 9003; --xdebug=profile,trace also works
npx wp-env status                      # runtime, URL, ports; --json for scripts
npx wp-env run cli wp option get home  # WP-CLI in the cli container
npx wp-env run cli wp plugin list --status=active
npx wp-env logs                        # PHP and container logs
npx wp-env stop                        # stop containers, free the ports
npx wp-env reset                       # empty the development database
npx wp-env cleanup --force             # remove containers, volumes and files, keep images
npx wp-env destroy --force             # remove everything, including images

wp-env run targets cli, wordpress, mysql, composer or phpmyadmin and starts in the WordPress root (--env-cwd=wp-content/plugins/lumen-catalog changes that, for example for composer install). It treats options it knows as its own: wp-env run cli php -- --help is needed to pass --help to PHP. In wp-env 12 the database reset command is wp-env reset; wp-env clean still works but prints a deprecation warning, and the "Get started with wp-env" handbook page still shows clean all. --spx enables the SPX profiler. The Lumen Catalog import command runs the same way (wp-env run cli wp lumen import, see WP-CLI).

wp-env 12 also has an experimental Playground runtime (wp-env start --runtime=playground): no Docker, SQLite, Xdebug and Multisite supported, but no wp-env run, SPX or MariaDB version. The runtime chosen at the first start is kept until wp-env destroy.

Common problems: a busy port 8888 (stop the other project, set WP_ENV_PORT, or use --auto-port); Docker not running (wp-env reports a Docker Compose error); configuration changes not applied (wp-env start --update); a broken install (wp-env reset, and as a last resort wp-env destroy, which deletes all local content); "no space left on device" while pulling images (the Docker disk is full); MariaDB 5.5 on Apple silicon, which exists only for amd64 and needs DOCKER_DEFAULT_PLATFORM=linux/amd64, running every container under emulation.

Docker Compose with the official images

A hand-written Compose file is the better choice when the project needs a specific web server, PHP-FPM, MySQL instead of MariaDB, or the same images as a containerised production. The official wordpress image has apache (default), fpm and fpm-alpine variants for PHP 8.2 to 8.5, and a cli variant with WP-CLI on Alpine. On first start, the entrypoint copies WordPress into an empty /var/www/html and writes a wp-config.php that reads the WORDPRESS_* variables through getenv(). The database must exist; the mariadb image creates it from MARIADB_DATABASE.

name: lumen-press

services:
  db:
    image: mariadb:11.8
    environment:
      MARIADB_DATABASE: lumen
      MARIADB_USER: lumen
      MARIADB_PASSWORD: ${LUMEN_DB_PASSWORD:?Set LUMEN_DB_PASSWORD in .env}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 20

  wordpress:
    image: wordpress:7.1.3-php8.4-apache
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:80"
    environment: &wp-env
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: lumen
      WORDPRESS_DB_USER: lumen
      WORDPRESS_DB_PASSWORD: ${LUMEN_DB_PASSWORD}
      WORDPRESS_TABLE_PREFIX: lp_
      WORDPRESS_DEBUG: "1"
      WORDPRESS_CONFIG_EXTRA: |
        define( 'WP_ENVIRONMENT_TYPE', 'local' );
        define( 'WP_DEBUG_LOG', true );
        define( 'WP_DEBUG_DISPLAY', true );
    volumes: &wp-volumes
      - wp-core:/var/www/html
      - ./lumen-catalog:/var/www/html/wp-content/plugins/lumen-catalog
      - ./themes/lumen:/var/www/html/wp-content/themes/lumen
      - ./mu-plugins:/var/www/html/wp-content/mu-plugins

  cli:
    image: wordpress:cli-2.12.0-php8.4
    profiles: ["tools"]
    user: "33:33"
    depends_on:
      db:
        condition: service_healthy
    environment:
      <<: *wp-env
      HOME: /tmp
    volumes: *wp-volumes

volumes:
  db-data:
  wp-core:

The password comes from a .env file next to compose.yaml that is never committed; the repository carries only .env.example with LUMEN_DB_PASSWORD=<choose-a-local-password>. Starting the stack and installing WordPress:

cp .env.example .env                    # then set a local-only password
docker compose up -d
docker compose run --rm cli wp core install \
  --url=http://localhost:8080 --title="Lumen Press" \
  --admin_user=admin --admin_email=admin@lumenpress.test --skip-email
docker compose run --rm cli wp plugin activate lumen-catalog
docker compose run --rm cli wp theme activate lumen
docker compose run --rm cli wp rewrite structure '/%postname%/'
docker compose run --rm cli wp eval 'echo wp_get_environment_type(), PHP_EOL;'
docker compose down        # stop; add -v to delete the database and core volumes

Without --admin_password, wp core install generates a random password and prints it once. The choices in the file avoid these errors:

  • Health check: depends_on with condition: service_healthy waits for MariaDB; otherwise the first requests show "Error establishing a database connection".

  • Port bound to 127.0.0.1: "8080:80" alone publishes a site that displays errors on every network interface.

  • User 33:33 for WP-CLI: Debian-based wordpress images run Apache as www-data with UID 33, Alpine cli images use UID 82. Without user: "33:33", files WP-CLI creates are not writable by Apache. HOME=/tmp gives WP-CLI a writable cache.

  • Shared environment: the cli container reads the same wp-config.php, so it needs the same WORDPRESS_* variables (the YAML anchor &wp-env).

  • WORDPRESS_CONFIG_EXTRA: PHP evaluated inside wp-config.php, the place for WP_ENVIRONMENT_TYPE. Keys and salts default to random values; every supported variable has a _FILE form that reads a Docker secret.

  • Core volume: changing the image tag does not update core inside an existing wp-core volume; run wp core update or recreate the volume.

  • Table prefix: lp_ matches the Lumen Press production prefix, so imported dumps need no renaming (see Database Schema).

Docker itself is covered in Compose for Local Development; production containers in Containers, Docker and Kubernetes.

WordPress Playground

Playground runs WordPress entirely in WebAssembly: PHP compiled to WASM and SQLite instead of MySQL, with no server. https://playground.wordpress.net/ starts a site in seconds in the browser. It keeps up to five autosaves in browser storage, which are recovery points, not backups; a temporary Playground (?storage=temp) is lost when the tab is closed or refreshed. The WebAssembly files (15 to 30 MB) are downloaded first, large plugins take 30 to 60 seconds to load, and not every WP-CLI command is supported. It suits demos, bug reproduction and teaching rather than daily work on a large site.

A Blueprint is a JSON file describing a Playground site: versions, landing page, login and a list of steps. The same file drives the browser and the Playground CLI. Lumen Press keeps one in the repository:

{
	"$schema": "https://playground.wordpress.net/blueprint-schema.json",
	"landingPage": "/wp-admin/edit.php?post_type=lp_book",
	"preferredVersions": { "php": "8.4", "wp": "7.1" },
	"login": true,
	"steps": [
		{ "step": "activatePlugin", "pluginPath": "lumen-catalog/lumen-catalog.php" },
		{ "step": "setSiteOptions", "options": { "blogname": "Lumen Press (Playground)" } },
		{ "step": "wp-cli", "command": "wp post create --post_type=lp_book --post_title='The Lantern Keeper' --post_status=publish" }
	]
}

The Playground CLI

@wp-playground/cli serves a Playground site on a local port. start detects the project type, persists the site in ~/.wordpress-playground/sites/<hash of the directory>/ and opens a browser. server gives full control; it keeps files and the SQLite database in a temporary directory unless wp-content is mounted.

# Simple mode: run from the plugin directory; the site is kept between runs
cd lumen-catalog && npx @wp-playground/cli@3.1.61 start --php=8.4

# Advanced mode: mount the plugin and apply the Blueprint on port 9400
npx @wp-playground/cli@3.1.61 server \
  --port=9400 --php=8.4 --wp=7.1 \
  --mount=./lumen-catalog:/wordpress/wp-content/plugins/lumen-catalog \
  --blueprint=./blueprint.json

run-blueprint applies a Blueprint without a web server and build-snapshot produces a ZIP of the result. --mount-before-install mounts before installation, for example a full WordPress checkout at /wordpress/. The CLI logs every visitor in as the administrator, so it is for localhost only. Migrating from wp-now: npx @wp-now/wp-now start becomes npx @wp-playground/cli@latest start, and --wp, --php, --blueprint, --skip-browser and --reset keep their meaning. Only the storage changes: wp-now start --path=./plugin stored the site for that path, while start stores it for the directory it runs from, so cd ./plugin first.

Desktop applications

WordPress Studio, an open source application from Automattic for macOS, Windows and Linux, creates local sites, starts them from Blueprints and can sync with WordPress.com and Pressable sites. Local (LocalWP, from WP Engine) creates sites with a chosen web server, PHP and MySQL version. MAMP and XAMPP install a general Apache, PHP and MySQL stack on which WordPress is installed by hand; DDEV and Lando are Docker-based project tools; VVV is a Vagrant set-up. None is endorsed here. What matters is the same for every tool: PHP and database versions that match production, a configuration reproducible from files in the repository, and a site that is not reachable from the network. A purely graphical set-up is hard to reproduce on another machine or in CI, which is why Lumen Press commits its configuration files.

Staging copies

A staging site is a copy of production on the same hosting stack (PHP build, database server, web server, caches, CDN rules), used to approve a release. Managed hosts often create one with a click; on a self-managed server it is built from a database export and a copy of wp-content/uploads:

# On production
wp db export lumen-prod.sql --add-drop-table

# On staging (WP_ENVIRONMENT_TYPE is 'staging' in its wp-config.php)
wp db import lumen-prod.sql
wp search-replace 'https://lumenpress.example' 'https://staging.lumenpress.example' \
  --all-tables-with-prefix --skip-columns=guid --report-changed-only
wp cache flush
wp rewrite flush --hard

wp search-replace handles PHP-serialized values (options, widget settings), which a plain SQL REPLACE() corrupts because serialized strings store their length. The GUID column must never change. Run with --dry-run first and add --precise for complex serialized data. The same steps pull a copy into wp-env, with http://localhost:8888 as the target URL. Copying production data to a laptop is a transfer of personal data: delete or anonymise customers and subscribers first and keep dumps encrypted. Uploads and Multisite are covered in Importing, Exporting and Migrating.

A staging site must sit behind HTTP authentication or an IP allow list, must not be indexed, must not mail real users (password resets, order notifications, newsletters), and its cron jobs must not act on external services as production would, such as the Lumen Catalog distributor stock sync.

WP_ENVIRONMENT_TYPE

WordPress 5.5 added wp_get_environment_type(). It returns local, development, staging or production, read from the WP_ENVIRONMENT_TYPE environment variable or the constant of the same name; the constant wins. A missing or unknown value returns production, and the filters for custom types were removed in 5.5.1. With development and no WP_DEBUG defined, WordPress defines WP_DEBUG as true. The result is cached on the first call, so set the value in wp-config.php or the server environment, never later in a plugin. Hosts are asked to set staging on staging copies.

// wp-config.php on the Lumen Press staging server
define( 'WP_ENVIRONMENT_TYPE', 'staging' );
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', '/var/log/wordpress/lumen-staging-debug.log' );
define( 'WP_DEBUG_DISPLAY', false );

The Lumen Press must-use plugin, mapped into every environment above, keeps non-production copies out of search engines and stops them sending mail:

<?php
/**
 * Plugin Name: Lumen Environment Guard
 * Description: Keeps non-production copies of the Lumen Press site private and quiet.
 */

defined( 'ABSPATH' ) || exit;

if ( 'production' !== wp_get_environment_type() ) {
	// Never let search engines index local, development or staging copies.
	add_filter( 'wp_robots', 'wp_robots_no_robots' );

	// Do not send real email from copies of the site; log it instead. A local copy that
	// relays to Mailpit sets LUMEN_SMTP_HOST, so its mail goes through; staging never sends.
	$lumen_smtp_host = defined( 'LUMEN_SMTP_HOST' ) ? (string) LUMEN_SMTP_HOST : (string) getenv( 'LUMEN_SMTP_HOST' );
	if ( '' === $lumen_smtp_host || 'staging' === wp_get_environment_type() ) {
		add_filter(
			'pre_wp_mail',
			static function ( $short_circuit, $atts ) {
				error_log( sprintf( '[lumen] mail to %s suppressed: %s', implode( ', ', (array) $atts['to'] ), $atts['subject'] ) );
				return true;
			},
			10,
			2
		);
	}
}

pre_wp_mail (5.7) short-circuits wp_mail() when a callback returns a non-null value; true reports the mail as sent. The filter is skipped on a local or development copy whose LUMEN_SMTP_HOST points at a mail catcher such as Mailpit, as set up in Email Delivery, while staging never sends mail. wp_robots_no_robots() (5.7) adds noindex to the robots meta tag. Because a missing or misspelt value means production, code must never read "not configured" as "test".

Pitfalls and corrections to older books

  • A local site is not a staging site. One older book calls its desktop install a "local staging" environment. Staging runs on the production stack; a laptop runs another web server, PHP build and database and cannot reveal problems with OPcache, the object cache, permissions or the proxy.

  • Official tooling is missing from the older books. They name MAMP, XAMPP or Docker in general terms and build on a desktop application; none mentions wp-env, Playground, Blueprints or WP_ENVIRONMENT_TYPE, all available when they were published.

  • Plain permalinks are not a shortcut for development or staging. One older book suggests them because SEO does not matter there. Permalinks that differ from production hide rewrite bugs; keep the production structure (wp-env enables pretty permalinks by default) and rely on noindex.

  • Password protection alone is not enough for staging: set WP_ENVIRONMENT_TYPE to staging so code can disable mail, payments and external syncs.

  • Never push a lower environment’s database to production, and never deploy wp-env or Playground files; deploy code (see Deployment and CI/CD).