Composer-Based WordPress

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 has no dependency manager of its own, yet many teams manage a site like any PHP application: composer.json lists core, every plugin and every third-party theme, a committed composer.lock records the exact versions, and composer install rebuilds the same site on a laptop, in CI and in production. Composer itself (constraints, the lock file, autoloading, repositories) is explained in Composer and Packages; this page covers what is specific to WordPress, using the Lumen Press site as the example.

The page was written against WordPress 7.1.3 (released 2026-10-06), PHP 7.4 minimum and 8.3+ recommended, Composer 2.10.3, roots/wordpress-core-installer 4.0.0, composer/installers 2.3.0, vlucas/phpdotenv 5.8.0, Roots Bedrock 1.31.7 and WP-CLI 2.12.0, and checked on 2026-10-28. That day WordPress 7.1.4 shipped as a security release (one fix closes a remote code execution weakness in the upgrade process), so the Lumen Press project pins 7.1.4; 7.2 is in beta, due on 8 December 2026. Every command was run in Docker with composer:2 (PHP 8.5), wordpress:cli-2.12.0-php8.4 and mariadb:11.8.

Why manage a site with Composer

Concern Dashboard-managed site Composer-managed site

Who changes code

Any Administrator, on the live server

A developer, through a commit and a deployment

Versions in each environment

Whatever was last clicked there

Exactly the versions in composer.lock, everywhere

What is in Git

Usually only the custom theme and plugins

composer.json, composer.lock, configuration, custom code

Rolling back

Restore files from a backup

Deploy the previous commit

Private and commercial plugins

Uploaded as ZIP files by hand

Installed from a Git or private Composer repository

Security updates

Applied automatically by WordPress

Applied by the team, through a lock-file update and a deploy

The last row is the price: someone must watch for releases and ship them (Dependabot or Renovate help). The approach suits teams that already deploy through CI/CD; a site run by its editors alone is better served by automatic updates.

WordPress core as a Composer package

WordPress is not published on Packagist by the WordPress project. Two community packagings exist, and both are actively maintained:

Package What it installs Status on 2026-10-28

roots/wordpress (metapackage)

roots/wordpress-no-content (core without themes or plugins) plus the roots/wordpress-core-installer Composer plugin

Maintained by Roots; built by the open-source roots/wordpress-packager from the official downloads.wordpress.org ZIPs, with a SHA-1 checksum in the lock file. 7.1.4 was published on its release day.

roots/wordpress-full

Core with the default themes, Akismet and Hello Dolly, plus beta and RC releases

Same maintainer; for projects that want the standard download

johnpbloch/wordpress / johnpbloch/wordpress-core

Core (with the default content) from a Git mirror of WordPress, plus johnpbloch/wordpress-core-installer

Still maintained (7.1.3 was published on its release day; 7.1.4 was not yet listed a few hours after its release) and common in older projects; Bedrock uses roots/wordpress

WordPress cannot run from vendor/: the core installer plugin puts it in the directory named by extra.wordpress-install-dir (default wordpress/). Because Composer 2.2+ refuses to run plugins that are not listed in config.allow-plugins, the core installer and composer/installers must both be allowed, or the install stops with "composer/installers contains a Composer plugin which is blocked by your allow-plugins config". The no-content build has no wp-content directory, so the site defines its own (see The Lumen Press project) and installs a theme.

composer/installers and installer-paths

Plugins and themes are ordinary Composer packages whose type is one of the WordPress types known to composer/installers, which moves them out of vendor/: wordpress-plugin to wp-content/plugins/{$name}/, wordpress-theme to wp-content/themes/{$name}/, wordpress-muplugin to wp-content/mu-plugins/{$name}/ and wordpress-dropin to wp-content/{$name}/ (relative to the project root).

extra.installer-paths overrides the defaults. Each key is a path pattern using {$name}, {$vendor} and {$type}; each value lists what it applies to: a package name, a type: selector or a vendor: selector. The first matching pattern wins, so a pattern that moves one plugin to mu-plugins must come before the general plugin rule. installer-name in a package’s own extra renames its folder, and installer-disable in the root package turns installers off. A wordpress-dropin lands in a folder named after the package, not as a single file in wp-content, so drop-ins such as object-cache.php are normally copied by their plugin instead (for example by wp redis enable, see Options, Transients and Object Cache).

Plugins and themes from the WordPress.org directory

The plugin and theme directories are Subversion repositories, not Composer repositories. Two services mirror them as Composer repositories, with different vendor names:

Aspect WP Packages WPackagist

Repository URL

https://repo.wp-packages.org

https://wpackagist.org

Package names

wp-plugin/<slug>, wp-theme/<slug>

wpackagist-plugin/<slug>, wpackagist-theme/<slug>

Operator

Roots (the Bedrock maintainers); the default repository in Bedrock 1.31

The long-standing original; its ownership moved to WP Engine in March 2026, according to Roots

Refresh

Every five minutes, Composer 2 metadata-url protocol

Scans the directory every hour, by its own description

Untagged plugins (dev-trunk)

Pinned to an SVN revision, so the lock file is reproducible

A mutable trunk ZIP, so the same lock file can install different code

Plugins closed on WordPress.org

Removed, as on WordPress.org; listed on a status page and a closures API

Kept installable

The rows come from the WP Packages comparison and WPackagist’s home page. Closed plugins matter most: one closed for a vulnerability can keep installing from a lock file without warning. Moving from one to the other means replacing the repository and re-requiring each package under its new vendor name (composer remove wpackagist-plugin/two-factor, then composer require wp-plugin/two-factor); WP Packages also publishes a migration script.

Three properties of these mirrors are easy to miss:

  • Versions are the plugin’s SVN tags. A plugin whose current release exists only in trunk can be installed only as dev-trunk, which defeats the point of a lock file; prefer plugins that tag their releases.

  • The dist URL is the official downloads.wordpress.org ZIP, and the lock file records no checksum for it, unlike roots/wordpress-no-content. The lock file pins the version, not the bytes.

  • composer audit knows only advisories filed against Packagist package names, not WordPress plugins and themes. The WP Sec Adv repository (https://repo-wpsecadv.typist.tech), documented by Roots, adds advisories from the Wordfence Intelligence feed so that audit, require and update see them.

The Lumen Press project

Lumen Press keeps its site in one Git repository. Core goes to public/wp, content to public/content, and the document root is public; vendor/, .env and composer.json sit one level above, out of the web server’s reach.

lumen-press-site/
├── composer.json, composer.lock, .env.example, .gitignore
├── .env                     # NOT committed: local values and secrets
├── wp-cli.yml               # path: public/wp, server docroot: public
├── vendor/                  # Composer: autoloader, phpdotenv
└── public/                  # document root
    ├── index.php            # committed: front controller
    ├── wp-config.php        # committed: reads the environment
    ├── wp/                  # Composer: WordPress core
    └── content/             # WP_CONTENT_DIR
        ├── mu-plugins/
        │   ├── lumen-mu-loader.php   # committed
        │   └── two-factor/           # Composer
        ├── plugins/         # Composer: lumen-catalog, redis-cache, query-monitor (dev)
        ├── themes/lumen/    # committed: the site's own block theme
        └── uploads/         # content, never in Git

composer.json

{
	"name": "lumenpress/lumen-press-site",
	"description": "The Lumen Press website: WordPress core, plugins and themes managed with Composer.",
	"type": "project",
	"license": "proprietary",
	"repositories": [
		{
			"name": "lumen-catalog",
			"type": "vcs",
			"url": "https://git.lumenpress.example/lumen-catalog"
		},
		{
			"name": "wp-packages",
			"type": "composer",
			"url": "https://repo.wp-packages.org",
			"only": ["wp-plugin/*", "wp-theme/*"]
		}
	],
	"require": {
		"php": ">=8.4",
		"composer/installers": "^2.3",
		"lumenpress/lumen-catalog": "^0.1",
		"roots/wordpress": "7.1.4",
		"vlucas/phpdotenv": "^5.6",
		"wp-plugin/redis-cache": "^3.0",
		"wp-plugin/two-factor": "^0.17"
	},
	"require-dev": {
		"wp-plugin/query-monitor": "^4.1"
	},
	"extra": {
		"wordpress-install-dir": "public/wp",
		"installer-paths": {
			"public/content/mu-plugins/{$name}/": ["type:wordpress-muplugin", "wp-plugin/two-factor"],
			"public/content/plugins/{$name}/": ["type:wordpress-plugin"],
			"public/content/themes/{$name}/": ["type:wordpress-theme"]
		}
	},
	"config": {
		"platform": {
			"php": "8.4.0"
		},
		"optimize-autoloader": true,
		"sort-packages": true,
		"allow-plugins": {
			"composer/installers": true,
			"roots/wordpress-core-installer": true
		}
	},
	"minimum-stability": "stable"
}

The choices behind it:

  • Core is pinned exactly (7.1.4), so a core upgrade is always a deliberate commit. composer validate warns about exact constraints; that warning is expected here.

  • Plugins use caret ranges; the lock file still fixes the exact version (the install resolved Redis Object Cache 3.0.0, Two Factor 0.17.0 and Query Monitor 4.1.1).

  • only restricts the mirror to wp-plugin/* and wp-theme/*, so a package with the same name as an internal one can never be served from it (see "Filtering packages" in Repository priorities).

  • Two Factor is forced into mu-plugins by listing it before the general plugin rule: no Editor or Administrator can deactivate two-factor login from the dashboard.

  • Query Monitor is a development dependency, absent from --no-dev installs; config.platform.php is the production PHP version, so a laptop with a newer PHP never locks a version that production cannot run.

wp-config.php and .env

WordPress looks for wp-config.php in ABSPATH and one directory above it, so public/wp-config.php is found for core in public/wp/. It loads the Composer autoloader, reads an optional .env file with vlucas/phpdotenv, and moves the content directory with WP_CONTENT_DIR and WP_CONTENT_URL (both are required, as absolute path and full URL without a trailing slash; see wp-config.php Reference):

<?php
// Lumen Press: one level above core (public/wp), so WordPress finds it. Values come from the environment or ../.env.

$lumen_root = dirname( __DIR__ );

require_once $lumen_root . '/vendor/autoload.php';

if ( is_readable( $lumen_root . '/.env' ) ) {
	// Real environment variables win over the file; the file never overwrites them.
	$lumen_dotenv = Dotenv\Dotenv::createImmutable( $lumen_root );
	$lumen_dotenv->load();
	$lumen_dotenv->required( array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST', 'WP_HOME' ) )->notEmpty();
	$lumen_dotenv->required( 'WP_ENVIRONMENT_TYPE' )->allowedValues( array( 'local', 'development', 'staging', 'production' ) );
}

define( 'DB_NAME', $_ENV['DB_NAME'] ?? getenv( 'DB_NAME' ) );
define( 'DB_USER', $_ENV['DB_USER'] ?? getenv( 'DB_USER' ) );
define( 'DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? getenv( 'DB_PASSWORD' ) );
define( 'DB_HOST', $_ENV['DB_HOST'] ?? getenv( 'DB_HOST' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
$table_prefix = 'wp_';

foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ) as $lumen_key ) {
	define( $lumen_key, $_ENV[ $lumen_key ] ?? getenv( $lumen_key ) );
}

$lumen_home = rtrim( $_ENV['WP_HOME'] ?? getenv( 'WP_HOME' ), '/' );
define( 'WP_ENVIRONMENT_TYPE', $_ENV['WP_ENVIRONMENT_TYPE'] ?? getenv( 'WP_ENVIRONMENT_TYPE' ) ?: 'production' );
define( 'WP_HOME', $lumen_home );
define( 'WP_SITEURL', $lumen_home . '/wp' );
define( 'WP_CONTENT_DIR', __DIR__ . '/content' );
define( 'WP_CONTENT_URL', $lumen_home . '/content' );

// Composer owns the code: no dashboard installs, updates or file editing; core updates arrive as a new composer.lock.
define( 'DISALLOW_FILE_MODS', true );
define( 'AUTOMATIC_UPDATER_DISABLED', true );
define( 'WP_DEBUG', 'production' !== WP_ENVIRONMENT_TYPE );
define( 'WP_DEBUG_DISPLAY', false );

if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/wp/' );
}

require_once ABSPATH . 'wp-settings.php';

Dotenv::createImmutable() writes to $_ENV and $_SERVER but not to getenv(), and never overwrites a variable the server already set. Production can therefore skip the file entirely and receive the same names from PHP-FPM, Docker or Kubernetes; the $_ENV[...] ?? getenv() pairs read either source ($_ENV is empty for real variables when variables_order lacks E, as in the production php.ini). ABSPATH is defined here only because core lives in a sub-folder; the general rules for reading secrets from the environment and passing them to PHP-FPM are on wp-config.php Reference.

The committed .env.example lists the same names with placeholder values (change-me, generate-me) and the hint to generate each key with openssl rand -hex 32; developers copy it to .env. public/index.php is the usual front controller of the "own directory" method: it defines WP_USE_THEMES and requires wp/wp-blog-header.php.

Private plugins from a Git repository

lumen-catalog is not published anywhere public. Its own repository at https://git.lumenpress.example/lumen-catalog holds the composer.json shown in Plugin Fundamentals; for the site, three fields matter: name (lumenpress/lumen-catalog), type (wordpress-plugin) and the PSR-4 autoload rule. Each release is a Git tag (0.1.0), which the vcs repository turns into a version.

Composer reads the credentials for that host from auth.json (written by composer config http-basic.git.lumenpress.example deploy-bot "$LUMEN_GIT_TOKEN" and ignored by Git) or from the COMPOSER_AUTH environment variable in CI, never from composer.json (see Composer: Authentication for privately hosted packages).

Installed by the site, the plugin has no vendor/ folder of its own: the site’s autoloader maps Lumen\Catalog\ to public/content/plugins/lumen-catalog/src. A main file that stopped with an admin notice whenever its own vendor/autoload.php was missing would break here, so the one in Plugin Fundamentals checks for the class first and works both from the release ZIP and from a Composer-managed site:

use Lumen\Catalog\Plugin;

defined( 'ABSPATH' ) || exit;

// Release ZIP: the plugin's own vendor/. Composer-managed site: the site's autoloader already knows the classes.
if ( ! class_exists( Plugin::class ) ) {
	if ( ! is_readable( __DIR__ . '/vendor/autoload.php' ) ) {
		return; // After adding the "missing vendor/" admin notice shown in Plugin Fundamentals.
	}
	require_once __DIR__ . '/vendor/autoload.php';
}

add_action( 'plugins_loaded', array( Plugin::class, 'boot' ) );

A plain Git server offers no ZIP archives, so Composer clones the plugin and leaves a .git folder in its directory; the test server served /content/plugins/lumen-catalog/.git/HEAD with HTTP 200. Deny dot files in the web server (see Web Servers: Apache and nginx) or host the plugin where Composer can download archives (GitHub, GitLab, Private Packagist, Satis). Keep its Update URI header, so WordPress never offers a same-named directory plugin as an update.

Must-use plugins in sub-folders

WordPress loads only the PHP files placed directly in mu-plugins/; it never looks inside sub-folders. A wordpress-muplugin package (or Two Factor, forced there by installer-paths) lands in its own folder and is silently ignored until a single-file loader includes it. Lumen Press commits this loader:

<?php
/**
 * Plugin Name: Lumen MU Loader
 * Description: Loads the must-use plugins that Composer installs into their own folders.
 */

defined( 'ABSPATH' ) || exit;

// Plugins may write options while loading; skip them until WordPress is installed.
if ( ! is_blog_installed() ) {
	return;
}

// WordPress loads only the PHP files directly inside mu-plugins/, never files in sub-folders.
foreach ( array( 'two-factor/two-factor.php' ) as $lumen_mu_plugin ) {
	$lumen_mu_file = WPMU_PLUGIN_DIR . '/' . $lumen_mu_plugin;
	if ( is_readable( $lumen_mu_file ) ) {
		require_once $lumen_mu_file;
	} else {
		error_log( "Lumen MU Loader: missing {$lumen_mu_plugin}; run composer install." );
	}
}

The is_blog_installed() guard is not decoration: without it, wp core install on an empty database printed a "Table 'lumen_press.wp_options' doesn’t exist" error, because Two Factor calls register_uninstall_hook() (which writes an option) as soon as it loads. Must-use plugins also never run activation, deactivation or uninstall hooks, so a plugin that creates tables or roles on activation needs that step run another way. Bedrock’s autoloader (below) discovers the folders with get_plugins(), caches the list in the bedrock_autoloader site option and fires activate_{$plugin} once for each newly found plugin, which covers the common cases without a hand-written list.

Installing, serving and installing WordPress

From the project root, with the database from Local Development Environments reachable as db:

docker run --rm -v "$PWD":/app -w /app composer:2 composer validate --no-check-all --strict
docker run --rm -v "$PWD":/app -w /app -e COMPOSER_AUTH composer:2 composer install
cp .env.example .env    # then set the database password and generate the eight keys

# WP-CLI reads path and docroot from wp-cli.yml.
docker run --rm --network lumen -v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 \
  wp core install --url=http://lumenpress.test:8080 --title="Lumen Press" \
  --admin_user=lp_admin --admin_email=webmaster@lumenpress.example --skip-email
docker run --rm --network lumen -v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 \
  sh -c 'wp theme activate lumen && wp plugin activate lumen-catalog redis-cache query-monitor'
docker run -d --name lumen-web --network lumen --network-alias lumenpress.test -p 8080:8080 \
  -v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 wp server --host=0.0.0.0 --port=8080

The install placed 14 packages, each in the folder of the tree above, and WP-CLI reported:

$ wp plugin list
name            status    update  version  update_version  auto_update
lumen-catalog   active    none    0.1.0                    off
query-monitor   active    none    4.1.1                    off
redis-cache     active    none    3.0.0                    off
lumen-mu-loader must-use                                   off

wp core version printed 7.1.4, WP_CONTENT_DIR was /app/public/content and Two_Factor_Core was loaded. Served by wp server, / and /wp/wp-login.php answered 200, theme files came from /content/themes/lumen/ and core assets from /wp/wp-includes/; /.env was not reachable, because it is outside the document root. In production the document root of nginx or Apache is public/, as described in Web Servers: Apache and nginx.

composer.lock and deterministic deploys

composer update resolves constraints and rewrites composer.lock; composer install reads only the lock file and installs exactly what it lists. The rule for a WordPress site is the same as for any application: update on a developer machine or in a dependency-update pull request, commit the lock file, and only ever install in CI and on servers.

composer validate --no-check-all --strict          # composer.json valid and lock file in sync
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
composer check-platform-reqs --no-dev              # the server's PHP and extensions satisfy the lock file
composer audit --locked                            # known advisories (add WP Sec Adv for plugins and themes)

A release build from a fresh clone installed 13 packages (Query Monitor absent) at the same versions as the developer install. Two failure modes were checked on purpose:

  • A lock file out of sync. After adding a plugin to composer.json by hand without updating, composer install stopped with "The lock file is not up to date with the latest changes in composer.json" and exit code 4, and composer validate reported the same as a lock-file error. Add packages with composer require, which updates both files together.

  • --strict and exact versions. composer validate --strict returned 1 only because of the exact roots/wordpress constraint; --no-check-all skips the constraint checks while keeping schema and lock checks strict.

Upgrading core is a one-package update, committed like any other change. With Bedrock’s default 7.1.3:

composer require roots/wordpress:7.1.4 --with-all-dependencies
wp core update-db

Both roots/wordpress packages moved to 7.1.4 and wp core update-db found the database already at version 61833. composer audit reported no advisory for 7.1.3 at that moment, so it cannot be the only signal for security releases.

Three things work against the lock file and must be closed off:

  • The dashboard. DISALLOW_FILE_MODS removes the install, update and delete screens and turns automatic updates off (see Security Hardening). Without it, an update clicked in production is undone by the next deploy.

  • WP-CLI. The constant does not bind WP-CLI: wp plugin install hello-dolly succeeded on the test site with DISALLOW_FILE_MODS set. Anything installed that way is untracked and disappears on the next deploy.

  • Background updates and Git. A site checked out with Git has a .git folder above public/wp, which WordPress treats as a version-control checkout and so skips automatic updates even without the constant (see Updates and Auto-Updates).

What to commit and what not

Path In Git Why

composer.json, composer.lock

Yes

The site’s dependency list and its exact resolution

wp-config.php, public/index.php, wp-cli.yml, .env.example

Yes

Configuration code without secrets

Own theme (themes/lumen/), single-file mu-plugins

Yes

Code that exists only in this repository

vendor/, public/wp/, Composer-installed plugins, themes and mu-plugin folders

No

Rebuilt from the lock file

uploads/, upgrade/, cache/, object-cache.php

No

Content or runtime files; backed up, not versioned

.env, auth.json

Never

Secrets and repository credentials

/vendor/
/public/wp/
/public/content/plugins/*
/public/content/mu-plugins/*/
/public/content/themes/*
!/public/content/themes/lumen/
/public/content/uploads/
/public/content/upgrade/
/public/content/cache/
/public/content/object-cache.php
*.log
.env
.env.*
!.env.example
auth.json

Source: Git: gitignore

With this file, git add -A on the installed project staged exactly eleven files: the two Composer files, .env.example, .gitignore, wp-cli.yml, public/index.php, public/wp-config.php, the loader and the three theme files. mu-plugins/*/ ignores Composer’s folders while keeping single-file must-use plugins tracked. A third-party theme from the directory would be required as wp-theme/<slug> and stay ignored; the custom theme is kept in the repository, as Roots also recommends.

The Roots Bedrock layout

Bedrock is the best-known ready-made Composer layout for WordPress, maintained by Roots under the MIT licence. It is an ecosystem project, not part of WordPress, and requires PHP 8.3 or later. It is created as a new project from a pinned release:

docker run --rm -v "$PWD":/app -w /app composer:2 composer create-project roots/bedrock:1.31.7 lumen-bedrock

Bedrock 1.31.7 locked roots/wordpress 7.1.3, roots/bedrock-autoloader 1.1.0, roots/bedrock-disallow-indexing 2.1.0, roots/wp-config 1.0.0, vlucas/phpdotenv 5.8.0, oscarotero/env and wp-theme/twentytwentyfive 1.5 from WP Packages, plus Pest and Laravel Pint for development. The layout:

lumen-bedrock/
├── composer.json, composer.lock, .env.example, wp-cli.yml
├── config/
│   ├── application.php      # the real configuration (wp-config.php equivalent)
│   └── environments/        # development.php, staging.php overrides
├── vendor/
└── web/                     # document root
    ├── index.php
    ├── wp-config.php        # loader: autoload, config/application.php, wp-settings.php
    ├── wp/                  # WordPress core (Composer)
    └── app/                 # WP_CONTENT_DIR: mu-plugins, plugins, themes, uploads

How Bedrock’s pieces map to the concepts on this page:

Piece Behaviour

config/application.php

Loads .env (and .env.local over it) with phpdotenv into an immutable repository; requires WP_HOME and WP_SITEURL, plus DB_NAME, DB_USER and DB_PASSWORD unless DATABASE_URL is set; reads values with env() from oscarotero/env, which converts true, null and numbers

WP_ENV

development, staging or production (the default); loads config/environments/<WP_ENV>.php and sets WP_ENVIRONMENT_TYPE from it unless that is given separately

Content directory

CONTENT_DIR is /app: WP_CONTENT_DIR is web/app, WP_CONTENT_URL is WP_HOME + /app

Production defaults

DISALLOW_FILE_MODS, DISALLOW_FILE_EDIT and AUTOMATIC_UPDATER_DISABLED on, debug display off; development.php turns debugging on and DISALLOW_FILE_MODS off

Roots\WPConfig\Config

Collects Config::define() calls and applies them once, so an environment file can override a value before it becomes a constant

bedrock-disallow-indexing

Discourages search engines whenever WP_ENV is not production

With a .env holding the database settings, WP_ENV='development', WP_SITEURL="${WP_HOME}/wp" and eight generated keys, wp core install succeeded; WP-CLI reported WP_CONTENT_DIR /app/web/app, environment type development and version 7.1.3, and wp server answered 200 with theme assets under /app/themes/twentytwentyfive/. Existing sites can be converted with the Lithify WP-CLI command that Roots documents, or by hand with a search-replace of wp-content paths (see Importing, Exporting and Migrating).

Errors and pitfalls

Symptom Cause and fix

"composer/installers contains a Composer plugin which is blocked by your allow-plugins config"

Composer 2.2+ needs config.allow-plugins for composer/installers and roots/wordpress-core-installer

A plugin lands in the wrong folder

installer-paths is matched in order and the general rule came first

A must-use plugin does nothing

It sits in a sub-folder of mu-plugins; add it to the loader or use Bedrock’s autoloader

White screen, theme assets 404

WP_CONTENT_URL missing or not matching WP_HOME, or the document root is the project root instead of public/

"Your requirements could not be resolved" for a plugin

The plugin has no SVN tag for that version (only dev-trunk), or it was closed on WordPress.org

composer install exits 4

composer.json changed without composer update; run composer update <package> and commit both files

A Bedrock staging site is indexed and shows no debug output

WP_ENV is unset, so Bedrock runs it as production; set WP_ENV=staging

Where the books fall short

None of WordPress All-in-One For Dummies, Pro WordPress or the Ultimate WordPress Handbook runs a site with Composer: plugins and themes are installed from the dashboard or by upload, routes a Composer site closes on purpose. The Dummies book presents DISALLOW_FILE_MODS as only switching updates off, whereas it removes installing, updating and deleting altogether, which is why Composer sites set it. Pro WordPress changes ABSPATH after moving wp-config.php; the "one level up" move needs no change, and the layouts here define it only because core has its own sub-folder.