Skip to content

Instantly share code, notes, and snippets.

@westonruter
Last active June 3, 2026 23:10
Show Gist options
  • Select an option

  • Save westonruter/631424e1b7f387a28c349c35437aaf15 to your computer and use it in GitHub Desktop.

Select an option

Save westonruter/631424e1b7f387a28c349c35437aaf15 to your computer and use it in GitHub Desktop.

Status: Draft (The final form of this may move to a blog post or a full repo.)

This describes how to set up the repos for WordPress/wordpress-develop, WordPress/gutenberg, and WordPress/performance in one single place all using their own built-in environments (in Docker). Importantly, the latter two repos (for plugins) are configured to use the core files from the repos for both their development and tests environments.

Benefits:

  1. WordPress core files are shared across all environments, including when running unit tests. So if you run PHPUnit tests for Gutenberg or Performance Lab, any core changes you've made will be reflected. This resolves a big painpoint with trying out core patches in plugin unit tests.
  2. Plugins, mu-plugins, and themes and are shared across all environments, although you can add additional environment-specific extensions in your .wp-env.override.json. To add a new plugin or theme, you can just install it in WordPress via any of the environments. Or you can clone a new repo into the themes/plugins directories.
  3. The same wp-content/debug.log is shared by all environment (by default).
  4. You can run the tests for each project in their own respective built-in environments.

The repos for gutenberg and performance are cloned into the src/wp-content/plugins directory of the wordpress-develop repo.

Set up WordPress core (WordPress/wordpress-develop)

Clone repo and enter:

git clone https://github.com/WordPress/wordpress-develop.git
cd wordpress-develop
wordpress_develop_dir=$(pwd)

Add remote to my fork, including setting my fork as the origin remote for pushing:

git remote add westonruter https://github.com/westonruter/wordpress-develop.git
git remote set-url --push origin https://github.com/westonruter/wordpress-develop.git

Also add the SVN repo per svn-git-up.

Install dependencies and build:

npm install
composer install
npm run build:dev

Set up plugins

From the wordpress-develop directory:

cd src/wp-content/plugins
plugins_dir=$(pwd)

Add Gutenberg (WordPress/gutenberg)

Clone repo and enter:

cd "$plugins_dir"
git clone https://github.com/WordPress/gutenberg.git
cd gutenberg

Add override files that set unique port numbers for this environment while also reusing core files from wordpress-develop.

Note

@wordpress/env deprecated starting both a development and a tests environment by default, along with the env, testsPort, and testsEnvironment options. The tests environment is now a separate config file (.wp-env.test.json) started via wp-env --config. Accordingly, the local overrides are split into two files: .wp-env.override.json (development) and .wp-env.test.override.json (tests). Both must be flat — the env key is ignored.

cat << 'WPENVOVERRIDE' > .wp-env.override.json
{
  "$schema": "./schemas/json/wp-env.json",
  "core": "../../../",
  "port": 8891,
  "phpmyadminPort": 9001
}
WPENVOVERRIDE

cat << 'WPENVOVERRIDE' > .wp-env.test.override.json
{
  "$schema": "./schemas/json/wp-env.json",
  "core": "../../../",
  "port": 8991,
  "mappings": {
    "/wordpress-phpunit": "../../../../tests/phpunit"
  }
}
WPENVOVERRIDE

Install dependencies and build:

npm install
composer install
npm run build

Add Performance Lab (WordPress/performance)

Clone repo and enter:

cd "$plugins_dir"
git clone https://github.com/WordPress/performance.git
cd performance

As with Gutenberg, add the split override files that set unique port numbers for this environment while also reusing core files from wordpress-develop. The tests override also adds a mappings entry so the WordPress PHPUnit test suite from wordpress-develop is used:

cat << 'WPENVOVERRIDE' > .wp-env.override.json
{
  "$schema": "https://schemas.wp.org/trunk/wp-env.json",
  "core": "../../../",
  "port": 8890,
  "phpVersion": "8.3",
  "phpmyadminPort": 9000
}
WPENVOVERRIDE

cat << 'WPENVOVERRIDE' > .wp-env.test.override.json
{
  "$schema": "https://schemas.wp.org/trunk/wp-env.json",
  "core": "../../../",
  "port": 8990,
  "phpVersion": "8.3",
  "mappings": {
    "/wordpress-phpunit": "../../../../tests/phpunit"
  }
}
WPENVOVERRIDE

Install dependencies and build:

npm install
composer install
npm run build

Add symlinks so the Performance Lab plugins are available in wordpress-develop:

cd "$plugins_dir"
for plugin in $(ls performance/plugins); do ln -s "performance/plugins/$plugin"; done

Add Misc Themes and Plugins

Clone these repos into src/wp-content/plugins as desired:

cd "$plugins_dir"
repos=(
  od-admin-ui
  od-content-visibility
  od-default-disabled
  od-dev-mode
  od-intrinsic-dimensions
  od-store-query-vars
  od-store-user-agent
)
for repo in "${repos[@]}"; do
  git clone https://github.com/westonruter/$repo.git
done

Start environments for the first time

First, start up the wordpress-develop environment:

cd "$wordpress_develop_dir"
npm run env:start
npm run env:install

This causes the wp-config.php file to be emitted at the wordpress-develop repo root.

Note that at this point there is no wp-config.php file in the src directory.

This is the tricky part, to get multiple wp-env instances to use the same wp-config.php while also preventing wordpress-develop from using it.

First, start up the Gutenberg environment:

cd "$plugins_dir/gutenberg"
npm run wp-env start

Expected output (note that wp-env start now only starts the single development environment; the tests environment is started separately via wp-env --config .wp-env.test.json start, e.g. npm run wp-env-test start):

WordPress development site started at http://localhost:8891
MySQL is listening on port 58145
phpMyAdmin started at http://localhost:9001

At this point you'll be able to successfully navigate to the Gutenberg development environment, but if you try going to the wordpress-development environment, you'll see an error from WordPress:

Warning: mysqli_real_connect(): (HY000/1045): Access denied for user 'example username'@'172.18.0.3' (using password: YES) in /var/www/src/wp-includes/class-wpdb.php on line 1988

This is because when starting wp-env for Gutenberg, it created a wp-config.php in the src directory of wordpress-develop, and WordPress defaults to using the wp-config.php at its root directory before looking one directory above, which is what wordpress-develop normally does. The wp-config.php generated by wp-env comes from wp-config-docker.php in docker-library/wordpress, and then wp-env uses WP-CLI to set the WP constants to be whatever is located in the .wp-env.json/.wp-env.override.json. For example:

wp-config.php
<?php
/**
 * The base configuration for WordPress
 *
 * The wp-config.php creation script uses this file during the installation.
 * You don't have to use the website, you can copy this file to "wp-config.php"
 * and fill in the values.
 *
 * This file contains the following configurations:
 *
 * * Database settings
 * * Secret keys
 * * Database table prefix
 * * ABSPATH
 *
 * This has been slightly modified (to read environment variables) for use in Docker.
 *
 * @link https://developer.wordpress.org/advanced-administration/wordpress/wp-config/
 *
 * @package WordPress
 */

// IMPORTANT: this file needs to stay in-sync with https://github.com/WordPress/WordPress/blob/master/wp-config-sample.php
// (it gets parsed by the upstream wizard in https://github.com/WordPress/WordPress/blob/f27cb65e1ef25d11b535695a660e7282b98eb742/wp-admin/setup-config.php#L356-L392)

// a helper function to lookup "env_FILE", "env", then fallback
if (!function_exists('getenv_docker')) {
	// https://github.com/docker-library/wordpress/issues/588 (WP-CLI will load this file 2x)
	function getenv_docker($env, $default) {
		if ($fileEnv = getenv($env . '_FILE')) {
			return rtrim(file_get_contents($fileEnv), "\r\n");
		}
		else if (($val = getenv($env)) !== false) {
			return $val;
		}
		else {
			return $default;
		}
	}
}

// ** Database settings - You can get this info from your web host ** //
/** The name of the database for WordPress */
define( 'DB_NAME', getenv_docker('WORDPRESS_DB_NAME', 'wordpress') );

/** Database username */
define( 'DB_USER', getenv_docker('WORDPRESS_DB_USER', 'example username') );

/** Database password */
define( 'DB_PASSWORD', getenv_docker('WORDPRESS_DB_PASSWORD', 'example password') );

/**
 * Docker image fallback values above are sourced from the official WordPress installation wizard:
 * https://github.com/WordPress/WordPress/blob/1356f6537220ffdc32b9dad2a6cdbe2d010b7a88/wp-admin/setup-config.php#L224-L238
 * (However, using "example username" and "example password" in your database is strongly discouraged.  Please use strong, random credentials!)
 */

/** Database hostname */
define( 'DB_HOST', getenv_docker('WORDPRESS_DB_HOST', 'mysql') );

/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', getenv_docker('WORDPRESS_DB_CHARSET', 'utf8') );

/** The database collate type. Don't change this if in doubt. */
define( 'DB_COLLATE', getenv_docker('WORDPRESS_DB_COLLATE', '') );

/**#@+
 * Authentication unique keys and salts.
 *
 * Change these to different unique phrases! You can generate these using
 * the {@link https://api.wordpress.org/secret-key/1.1/salt/ WordPress.org secret-key service}.
 *
 * You can change these at any point in time to invalidate all existing cookies.
 * This will force all users to have to log in again.
 *
 * @since 2.6.0
 */
define( 'AUTH_KEY',         getenv_docker('WORDPRESS_AUTH_KEY',         'de8724d4375446bc55cab44d0e30dd5fd889c3fd') );
define( 'SECURE_AUTH_KEY',  getenv_docker('WORDPRESS_SECURE_AUTH_KEY',  '26a684919680c6f85faa6e665a382fc653da2032') );
define( 'LOGGED_IN_KEY',    getenv_docker('WORDPRESS_LOGGED_IN_KEY',    'e039bb46676f860f8ba591250c3d1f0ff0afa405') );
define( 'NONCE_KEY',        getenv_docker('WORDPRESS_NONCE_KEY',        '8095fdb8ffd5974f45018fae0833f19fa67f21ce') );
define( 'AUTH_SALT',        getenv_docker('WORDPRESS_AUTH_SALT',        '25587d086964742511fff7f9cf9c75fcf5c71bff') );
define( 'SECURE_AUTH_SALT', getenv_docker('WORDPRESS_SECURE_AUTH_SALT', 'b8b12ced9f4c27288ef2db5d43e489dd9dea1a41') );
define( 'LOGGED_IN_SALT',   getenv_docker('WORDPRESS_LOGGED_IN_SALT',   '87ecae825ca29c34d93b9517597aa8c9b67cfb7b') );
define( 'NONCE_SALT',       getenv_docker('WORDPRESS_NONCE_SALT',       '3d450ea8b90a58a3ba04aff962cde33e326ceadd') );
// (See also https://wordpress.stackexchange.com/a/152905/199287)

/**#@-*/

/**
 * WordPress database table prefix.
 *
 * You can have multiple installations in one database if you give each
 * a unique prefix. Only numbers, letters, and underscores please!
 *
 * At the installation time, database tables are created with the specified prefix.
 * Changing this value after WordPress is installed will make your site think
 * it has not been installed.
 *
 * @link https://developer.wordpress.org/advanced-administration/wordpress/wp-config/#table-prefix
 */
$table_prefix = getenv_docker('WORDPRESS_TABLE_PREFIX', 'wp_');

/**
 * For developers: WordPress debugging mode.
 *
 * Change this to true to enable the display of notices during development.
 * It is strongly recommended that plugin and theme developers use WP_DEBUG
 * in their development environments.
 *
 * For information on other constants that can be used for debugging,
 * visit the documentation.
 *
 * @link https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/
 */
define( 'FS_METHOD', 'direct' );
define( 'SCRIPT_DEBUG', true );
define( 'WP_ENVIRONMENT_TYPE', 'local' );
define( 'WP_PHP_BINARY', 'php' );
define( 'WP_TESTS_EMAIL', 'admin@example.org' );
define( 'WP_TESTS_TITLE', 'Test Blog' );
define( 'WP_TESTS_DOMAIN', 'localhost:8891' );
define( 'WP_SITEURL', 'http://localhost:8891' );
define( 'WP_HOME', 'http://localhost:8891' );
define( 'WP_DEBUG', true );

/* Add any custom values between this line and the "stop editing" line. */

// If we're behind a proxy server and using HTTPS, we need to alert WordPress of that fact
// see also https://wordpress.org/support/article/administration-over-ssl/#using-a-reverse-proxy
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
	$_SERVER['HTTPS'] = 'on';
}
// (we include this by default because reverse proxying is extremely common in container environments)

if ($configExtra = getenv_docker('WORDPRESS_CONFIG_EXTRA', '')) {
	eval($configExtra);
}

/* That's all, stop editing! Happy publishing. */

/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

/** Sets up WordPress vars and included files. */
require_once ABSPATH . 'wp-settings.php';

Most notably:

define( 'WP_TESTS_DOMAIN', 'localhost:8891' );
define( 'WP_SITEURL', 'http://localhost:8891' );
define( 'WP_HOME', 'http://localhost:8891' );

If the Gutenberg environment is currently running (in which the .wp-env.override.json has a port set to 8891), and then I now try starting the Performance Lab environment (in which the .wp-env.override.json has a port set to 8890), then wp-env updates the constants used in the wp-config.php created by wp-env in Gutenberg to use the values for the Performance Lab environment:

define( 'WP_TESTS_DOMAIN', 'localhost:8890' );
define( 'WP_SITEURL', 'http://localhost:8890' );
define( 'WP_HOME', 'http://localhost:8890' );

The result is attempting to access the Gutenberg environment (http://localhost:8891) will cause WordPress to do a canonical redirection (with 301 Moved Permanently) to the Performance Lab environment (http://localhost:8890), meaning you can't access both environments at the same time (besides the fact that the wordpress-develop environment is also broken right now).

So here is the solution I've come up with to ensure that these three environments are all able to run concurrently.

1️⃣ First, open the src/wp-config.php file, and add the following PHP code to the top of after the file header comment:

// Load the wp-config.php for wordpress-develop when the current request is coming from that environment.
if ( basename( __DIR__ ) === 'src' && file_exists( __DIR__ . '/../wp-config.php' ) ) {
	require __DIR__ . '/../wp-config.php';
	return;
}

At this point, the wordpress-develop environment (http://localhost:8889/) works again! 🎉

2️⃣ Second, we need to dynamically load the correct config constants for the current wp-env environment (e.g. Gutenberg or Performance Lab). This can be done be adding the following code right after the the if statement added above.

There are two ways the correct environment is determined:

  • Web requests have $_SERVER['SERVER_PORT'] set, so the environment is matched by port. Each plugin's config is now split across two files: .wp-env.json/.wp-env.override.json (development) and .wp-env.test.json/.wp-env.test.override.json (tests). Each pair is evaluated independently so the development and tests environments for a plugin can match their respective ports.
  • WP-CLI/PHPUnit runs (e.g. npm run test-php, npm run test:unit:php) have no SERVER_PORT. Instead the running plugin is identified by the working directory (set via wp-env run --env-cwd=…), walking up to find its .wp-env.test.json and applying the tests config. This is what keeps WP_DEBUG/SCRIPT_DEBUG false during tests so that minified assets are enqueued and markup snapshot assertions pass. (__DIR__ can't be used here, because this file is also copied to wp-tests-config.php in a different directory.)
( function () {
	$host = 'localhost';
	if ( isset( $_SERVER['SERVER_PORT'] ) ) {
		$host .= ':' . $_SERVER['SERVER_PORT'];
	}
	$env_config = array(
		'FS_METHOD'           => 'direct',
		'SCRIPT_DEBUG'        => true,
		'WP_DEBUG'            => true,
		'WP_DEBUG_LOG'        => true,
		'WP_DEVELOPMENT_MODE' => 'all',
		'WP_ENVIRONMENT_TYPE' => 'local',
		'WP_HOME'             => 'http://' . $host,
		'WP_PHP_BINARY'       => 'php',
		'WP_SITEURL'          => 'http://' . $host,
		'WP_TESTS_DOMAIN'     => $host,
		'WP_TESTS_EMAIL'      => 'admin@example.org',
		'WP_TESTS_TITLE'      => 'Test Blog',
	);
	if ( isset( $_SERVER['SERVER_PORT'] ) ) {
		$matched = false;
		foreach ( glob( __DIR__ . '/wp-content/plugins/*' ) as $plugin_dir ) {
			// Each wp-env config has its own (base, override) pair and is
			// evaluated independently: `.wp-env.json` is the development
			// environment, while `.wp-env.test.json` (started via
			// `wp-env --config`) is the separate tests environment.
			$variants = array(
				array( '/.wp-env.json', '/.wp-env.override.json' ),
				array( '/.wp-env.test.json', '/.wp-env.test.override.json' ),
			);
			foreach ( $variants as $variant ) {
				$env_port      = null;
				$plugin_config = array();
				foreach ( $variant as $config_file ) {
					if ( ! file_exists( $plugin_dir . $config_file ) ) {
						continue;
					}
					$plugin_env = json_decode( file_get_contents( $plugin_dir . $config_file ), true );
					if ( isset( $plugin_env['env']['development']['port'] ) ) {
						$env_port = $plugin_env['env']['development']['port'];
					} elseif ( isset( $plugin_env['port'] ) ) {
						$env_port = $plugin_env['port'];
					}
					if ( isset( $plugin_env['config'] ) ) {
						$plugin_config = array_merge( $plugin_config, $plugin_env['config'] );
					}
					if ( isset( $plugin_env['env']['development']['config'] ) ) {
						$plugin_config = array_merge( $plugin_config, $plugin_env['env']['development']['config'] );
					}
				}

				if ( $env_port && $env_port === (int) $_SERVER['SERVER_PORT'] ) {
					$env_config = array_merge( $env_config, $plugin_config );
					$matched    = true;
					break;
				}
			}
			if ( $matched ) {
				break;
			}
		}
	} elseif (
		isset( $_SERVER['argv'][0] ) &&
		str_contains( $_SERVER['argv'][0], 'phpunit' )
	) {
		// WP-CLI/PHPUnit requests have no SERVER_PORT, so the port-based lookup
		// above can't run. Identify which monorepo's suite is executing by the
		// working directory (set via `wp-env run --env-cwd=...`) and apply that
		// monorepo's tests environment config (`.wp-env.test.json`). This keeps
		// constants such as WP_DEBUG and SCRIPT_DEBUG aligned with the tests
		// environment (rather than the development defaults, which would enqueue
		// unminified assets and break markup snapshots) for any mapped plugin,
		// e.g. both performance and gutenberg. Note __DIR__ cannot be used to
		// locate the plugins here: this file is also copied to
		// wp-tests-config.php in a different directory, so walk up from the
		// working directory instead.
		$dir = (string) getcwd();
		while ( '' !== $dir && '/' !== $dir ) {
			if ( file_exists( $dir . '/.wp-env.test.json' ) ) {
				foreach ( array( '/.wp-env.test.json', '/.wp-env.test.override.json' ) as $config_file ) {
					if ( ! file_exists( $dir . $config_file ) ) {
						continue;
					}
					$plugin_env = json_decode( file_get_contents( $dir . $config_file ), true );
					if ( isset( $plugin_env['config'] ) ) {
						$env_config = array_merge( $env_config, $plugin_env['config'] );
					}
				}
				break;
			}
			$dir = dirname( $dir );
		}
	}

	foreach ( $env_config as $key => $value ) {
		// Guard against re-defining: this file (wp-config.php, and its
		// wp-tests-config.php copy) can be loaded more than once in a single
		// process (e.g. WP-CLI loads it twice), which would otherwise emit
		// "Constant already defined" warnings on the second pass.
		if ( ! defined( $key ) ) {
			define( $key, $value );
		}
	}
} )();

3️⃣ In order to prevent PHP from warning about the config constants already being defined, you can wrap the original constants in if ( false ) {:

+ if ( false ) {
  define( 'FS_METHOD', 'direct' );
  define( 'SCRIPT_DEBUG', true );
  define( 'WP_ENVIRONMENT_TYPE', 'local' );
  define( 'WP_PHP_BINARY', 'php' );
  define( 'WP_TESTS_EMAIL', 'admin@example.org' );
  define( 'WP_TESTS_TITLE', 'Test Blog' );
  define( 'WP_TESTS_DOMAIN', 'localhost:8890' );
  define( 'WP_SITEURL', 'http://localhost:8890' );
  define( 'WP_HOME', 'http://localhost:8890' );
  define( 'WP_DEVELOPMENT_MODE', 'plugin' );
  define( 'WP_DEBUG', true );
+ }

This is because wp-env will continue use WP-CLI to add/update/remove these constants. So this hack ensures that the wp config commands will succeed, by ultimately be ingored in favor of the logic added to dynamically read them from the .wp-env.json/.wp-env.override.json files.

Starting all environments

Each plugin now has two independent environments (development and tests), so start both. The wp-env-test script is shorthand for wp-env --config=.wp-env.test.json.

cd "$wordpress_develop_dir"
npm run env:start

cd "$plugins_dir/gutenberg"
npm run wp-env start       # development (http://localhost:8891)
npm run wp-env-test start  # tests (http://localhost:8991)

cd "$plugins_dir/performance"
npm run wp-env start       # development (http://localhost:8890)
npm run wp-env-test start  # tests (http://localhost:8990)

Running tests in all environments

The plugin PHP/E2E tests run against the tests environment, so make sure it is running first (see above). Note that after editing src/wp-config.php, run npm run wp-env-test start -- --update in the plugin so the change is copied into the regenerated wp-tests-config.php.

cd "$wordpress_develop_dir"
npm run test:php
npm run test:e2e
npm run test:visual

cd "$plugins_dir/gutenberg"
npm run test:unit:php # Starts the tests env (setup) then runs PHPUnit.
npm run test:unit
npm run test:e2e

cd "$plugins_dir/performance"
npm run wp-env-test start # Ensure the tests env is running.
npm run test-php
npm run test-e2e

Configuring PhpStorm

You can continue to have separate PhpStorm projects for all three repos.

TODO: Pointing to core files when opening Gutenberg or Performance Lab projects.

PRs merged to improve this:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment