Pantheon Composer-Managed WordPress with Bedrock: The Definitive Setup and Workflow Guide
Why Composer on Pantheon Matters
WordPress dependency management has long been a pain point. You download a ZIP, unzip it into wp-content/plugins/, and hope for the best. When your team grows, when you add staging environments, when you need reproducible builds, that approach falls apart fast.
Pantheon recognized this problem and shipped Integrated Composer support for WordPress. Now you can manage your entire WordPress stack, including core, plugins, themes, and mu-plugins, through composer.json. Every dependency gets version-locked. Every build is reproducible. Every deployment is predictable.
But there is a fork in the road. Pantheon offers its own wordpress-composer-managed upstream, which restructures the WordPress directory layout into something resembling Bedrock. Roots Bedrock is the original Composer-first WordPress boilerplate. Both aim to solve the same problem, but they solve it differently.
This guide covers both paths. We will walk through initial setup, daily workflow, deployment across Pantheon environments, CI/CD integration, and the gnarly troubleshooting scenarios you will definitely encounter. No hand-waving. Real configs, real commands, real solutions.
Pantheon’s wordpress-composer-managed vs. Roots Bedrock
Before writing a single line of config, you need to pick your path. This decision will affect your directory structure, your deployment pipeline, and how you handle environment variables for the next several years. Get it right now.
Pantheon’s wordpress-composer-managed Upstream
Pantheon maintains a GitHub repository at pantheon-systems/wordpress-composer-managed. It uses Integrated Composer, meaning Pantheon’s infrastructure runs composer install during Git push. You do not need a separate CI step to build dependencies.
The directory layout looks like this:
project-root/
├── composer.json
├── composer.lock
├── pantheon.yml
├── web/
│ ├── wp/ # WordPress core (installed by Composer)
│ ├── wp-content/
│ │ ├── plugins/ # Composer-managed plugins
│ │ ├── themes/ # Composer-managed themes
│ │ └── mu-plugins/ # Must-use plugins
│ ├── wp-config.php # Main config (loads from env)
│ └── index.php
├── private/
│ └── scripts/
│ └── quicksilver/ # Pantheon Quicksilver hooks
└── vendor/ # PHP dependencies
WordPress core lives in web/wp/. Content lives in web/wp-content/. The web/ directory is the docroot.
Key advantages: you get upstream updates from Pantheon directly. When Pantheon patches their Composer setup or adds new Quicksilver hooks, you can pull those changes. You also get Integrated Composer out of the box, so there is zero CI configuration required for basic builds.
Key trade-offs: you are locked into Pantheon’s directory structure. The environment variable handling is Pantheon-specific. If you ever migrate off Pantheon, you will need to refactor your configuration layer.
Roots Bedrock
Bedrock is the battle-tested, platform-agnostic Composer boilerplate for WordPress. It predates Pantheon’s Composer support by years and has a massive community behind it.
The Bedrock directory layout:
project-root/
├── composer.json
├── composer.lock
├── config/
│ ├── application.php # Main WordPress config
│ └── environments/
│ ├── development.php
│ ├── staging.php
│ └── production.php
├── web/
│ ├── app/ # Replaces wp-content
│ │ ├── plugins/
│ │ ├── themes/
│ │ └── mu-plugins/
│ ├── wp/ # WordPress core
│ ├── index.php
│ └── wp-config.php # Bedrock's bootstrap
├── vendor/
├── .env # Environment variables
├── .env.example
└── phpcs.xml
Bedrock uses vlucas/phpdotenv for environment variable management. Configuration lives in config/application.php with environment-specific overrides. The content directory is web/app/ instead of web/wp-content/.
Key advantages: platform-agnostic. You can deploy Bedrock to Pantheon, AWS, DigitalOcean, Kinsta, or anywhere else. The environment configuration system is cleaner and more flexible. The community is huge.
Key trade-offs: you lose Integrated Composer on Pantheon. You must use Build Tools or a CI pipeline to run composer install before deploying. That means more setup work upfront.
Which One Should You Pick?
If you are building exclusively for Pantheon and want the simplest possible workflow, use wordpress-composer-managed. You push code, Pantheon builds it. Done.
If you need platform portability, or if your team already knows Bedrock from other projects, use Bedrock. You will spend more time on CI/CD setup, but you get a cleaner abstraction layer.
If you are an agency deploying dozens of sites across multiple hosts, Bedrock wins. Standardize once, deploy everywhere.
For this guide, I will cover both paths in parallel where they diverge. Most of the Composer workflow, plugin management, and deployment strategy applies equally to both.
Initial Setup: Integrated Composer
Let us start with Pantheon’s native Composer upstream.
Creating the Site
Install Terminus if you have not already:
curl -O https://raw.githubusercontent.com/pantheon-systems/terminus-installer/master/builds/installer.phar
php installer.phar install
Authenticate:
terminus auth:login --machine-token=YOUR_MACHINE_TOKEN
Create a new site from the wordpress-composer-managed upstream:
terminus site:create my-wp-site "My WordPress Site" wordpress-composer-managed
This spins up a new Pantheon site with the Composer-managed structure already in place. Clone it locally:
terminus connection:info my-wp-site.dev --fields=git_url
git clone ssh://[email protected]:2222/~/repository.git my-wp-site
cd my-wp-site
Understanding pantheon.yml
The pantheon.yml file tells Pantheon how to handle your site. For Composer-managed sites, the critical setting is:
api_version: 1
build_step: true
web_docroot: true
php_version: 8.2
database:
version: 10.4
build_step_env:
COMPOSER_MEMORY_LIMIT: "-1"
The build_step: true directive enables Integrated Composer. When you push to Pantheon’s Git remote, the platform will detect your composer.json, run composer install --no-dev --no-interaction, and deploy the result.
The web_docroot: true directive tells Pantheon that your document root is the web/ subdirectory, not the repository root.
The composer.json Anatomy
Here is a stripped-down but realistic composer.json for the Pantheon upstream:
{
"name": "pantheon-systems/wordpress-composer-managed",
"type": "project",
"license": "MIT",
"description": "Pantheon's Composer-managed WordPress upstream.",
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org",
"only": [
"wpackagist-plugin/*",
"wpackagist-theme/*"
]
},
{
"type": "path",
"url": "upstream-configuration"
}
],
"require": {
"php": ">=8.0",
"composer/installers": "^2.2",
"johnpbloch/wordpress": "^6.4",
"pantheon-systems/wordpress-composer-managed": "*",
"vlucas/phpdotenv": "^5.5",
"wpackagist-plugin/wp-native-php-sessions": "^1.4",
"wpackagist-plugin/pantheon-advanced-page-cache": "^2.0",
"wpackagist-theme/flavor": "^1.1"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7"
},
"config": {
"vendor-dir": "vendor",
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.2"
},
"allow-plugins": {
"composer/installers": true,
"johnpbloch/wordpress-core-installer": true
}
},
"extra": {
"wordpress-install-dir": "web/wp",
"installer-paths": {
"web/wp-content/plugins/{$name}/": [
"type:wordpress-plugin"
],
"web/wp-content/themes/{$name}/": [
"type:wordpress-theme"
],
"web/wp-content/mu-plugins/{$name}/": [
"type:wordpress-muplugin"
]
}
},
"autoload": {
"classmap": [
"upstream-configuration/scripts/ComposerScripts.php"
]
},
"scripts": {
"pre-update-cmd": [
"WordPressComposerManaged\ComposerScripts::preUpdate"
],
"upstream-require": [
"WordPressComposerManaged\ComposerScripts::upstreamRequire"
]
},
"minimum-stability": "stable",
"prefer-stable": true
}
Several things to notice here. The repositories array includes WPackagist, which mirrors the entire WordPress.org plugin and theme directory as Composer packages. The installer-paths in the extra section controls where different package types get installed. WordPress core goes into web/wp/. Plugins go into web/wp-content/plugins/. Themes into web/wp-content/themes/.
The johnpbloch/wordpress package is the Composer-compatible fork of WordPress core. It is the de facto standard for managing WordPress core via Composer.
Initial Setup: Bedrock on Pantheon
Running Bedrock on Pantheon requires the Build Tools plugin for Terminus. This is because Bedrock does not use Integrated Composer; you need an external CI step to build the project.
Installing Build Tools
terminus self:plugin:install terminus-build-tools-plugin
Now create a new site using the Bedrock template:
terminus build:project:create
--team="Your Agency"
--org="your-github-org"
--visibility="private"
roots/bedrock
my-bedrock-site
This command does several things at once. It creates a GitHub repository, sets up a CI pipeline (CircleCI by default), creates a Pantheon site, and wires them together. The CI pipeline will run composer install and push the built artifact to Pantheon.
If you prefer GitHub Actions over CircleCI, you can configure that after initial setup. We will cover that later.
Bedrock’s composer.json
Bedrock’s composer.json is cleaner because it does not need Pantheon-specific scaffolding:
{
"name": "roots/bedrock",
"type": "project",
"license": "MIT",
"description": "WordPress boilerplate with Composer.",
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org",
"only": [
"wpackagist-plugin/*",
"wpackagist-theme/*"
]
}
],
"require": {
"php": ">=8.0",
"composer/installers": "^2.2",
"vlucas/phpdotenv": "^5.5",
"oscarotero/env": "^2.1",
"roots/bedrock-autoloader": "^1.0",
"roots/bedrock-disallow-indexing": "^2.0",
"roots/wordpress": "^6.4",
"roots/wp-config": "1.0.0",
"roots/wp-password-bcrypt": "1.1.0",
"wpackagist-plugin/wp-native-php-sessions": "^1.4",
"wpackagist-plugin/pantheon-advanced-page-cache": "^2.0"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"composer/installers": true,
"roots/wordpress-core-installer": true
}
},
"extra": {
"installer-paths": {
"web/app/plugins/{$name}/": [
"type:wordpress-plugin"
],
"web/app/themes/{$name}/": [
"type:wordpress-theme"
],
"web/app/mu-plugins/{$name}/": [
"type:wordpress-muplugin"
]
},
"wordpress-install-dir": "web/wp"
},
"scripts": {
"post-root-package-install": [
"php -r "copy('.env.example', '.env');""
],
"test": [
"phpcs"
]
},
"minimum-stability": "stable",
"prefer-stable": true
}
The big differences: Bedrock uses roots/wordpress instead of johnpbloch/wordpress. Plugins install to web/app/plugins/ instead of web/wp-content/plugins/. The roots/bedrock-autoloader package handles mu-plugin loading, which is critical and something we will discuss in the troubleshooting section.
Managing Plugins and Themes via Composer
This is where the day-to-day workflow lives. Every plugin install, update, and removal goes through Composer.
Adding Plugins from WPackagist
WPackagist mirrors every plugin and theme from WordPress.org. The naming convention is simple:
# Plugins: wpackagist-plugin/{slug}
composer require wpackagist-plugin/advanced-custom-fields
composer require wpackagist-plugin/wordpress-seo
composer require wpackagist-plugin/redirection
# Themes: wpackagist-theme/{slug}
composer require wpackagist-theme/flavor
The slug matches the WordPress.org URL. If the plugin page is wordpress.org/plugins/advanced-custom-fields/, the Composer package name is wpackagist-plugin/advanced-custom-fields.
Always specify version constraints:
composer require wpackagist-plugin/advanced-custom-fields:^6.2
composer require wpackagist-plugin/wordpress-seo:^21.0
The caret (^) constraint allows minor and patch updates but blocks major version bumps. This is usually what you want. For plugins that follow semver properly, this keeps you current without breaking changes. For plugins that do not follow semver (which is many WordPress plugins, unfortunately), you might want tighter constraints:
composer require wpackagist-plugin/some-risky-plugin:~5.3.0
The tilde (~) constraint with three version components only allows patch updates. Version 5.3.1 would be allowed. Version 5.4.0 would not.
Removing Plugins
composer remove wpackagist-plugin/hello-dolly
This removes the package from composer.json, updates composer.lock, and deletes the plugin files. Clean and traceable.
Updating Plugins
Update a specific plugin:
composer update wpackagist-plugin/wordpress-seo
Update everything:
composer update
See what is outdated:
composer outdated
A word of caution: never run composer update on all packages right before a production deployment. Update in small batches. Test each batch. Push to a multidev environment first. More on that workflow later.
Premium and Private Plugins
Many premium WordPress plugins are not on WPackagist. ACF Pro, Gravity Forms, WP Migrate, and others require separate handling. We will cover this in detail in the “Managing Private Plugins” section below.
Environment Configuration
Environment configuration is where the two approaches diverge most sharply. Getting this right is essential for a smooth deployment pipeline.
Pantheon wordpress-composer-managed
The Pantheon upstream uses a combination of environment variables set by the platform and a .env file for local development.
Pantheon automatically sets several environment variables on its infrastructure:
DB_NAME,DB_USER,DB_PASSWORD,DB_HOSTPANTHEON_ENVIRONMENT(dev, test, live, or the multidev name)PANTHEON_SITECACHE_HOST,CACHE_PORT(for Redis/Object Cache)
Your wp-config.php reads these:
<?php
/**
* wp-config.php for Pantheon Composer-managed WordPress.
*
* Reads from Pantheon environment variables when on platform,
* falls back to .env for local development.
*/
// Load Composer autoloader
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Load .env file if it exists (local development)
if (file_exists(dirname(__DIR__) . '/.env')) {
$dotenv = DotenvDotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
}
// Pantheon environment detection
if (defined('PANTHEON_ENVIRONMENT')) {
// On Pantheon: use platform variables
define('DB_NAME', $_ENV['DB_NAME']);
define('DB_USER', $_ENV['DB_USER']);
define('DB_PASSWORD', $_ENV['DB_PASSWORD']);
define('DB_HOST', $_ENV['DB_HOST'] . ':' . $_ENV['DB_PORT']);
define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', '');
// Salts are set via Pantheon dashboard or terminus
// They are injected as environment variables
// Environment-specific settings
$env = PANTHEON_ENVIRONMENT;
if ($env === 'live') {
define('WP_ENVIRONMENT_TYPE', 'production');
define('DISALLOW_FILE_EDIT', true);
define('DISALLOW_FILE_MODS', true);
} elseif ($env === 'test') {
define('WP_ENVIRONMENT_TYPE', 'staging');
define('DISALLOW_FILE_EDIT', true);
} else {
define('WP_ENVIRONMENT_TYPE', 'development');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
}
// Redis object cache
if (!empty($_ENV['CACHE_HOST'])) {
define('WP_REDIS_HOST', $_ENV['CACHE_HOST']);
define('WP_REDIS_PORT', $_ENV['CACHE_PORT']);
}
// Force HTTPS on live and test
if (in_array($env, ['live', 'test'], true)) {
define('FORCE_SSL_ADMIN', true);
$_SERVER['HTTPS'] = 'on';
}
} else {
// Local development: read from .env
define('DB_NAME', $_ENV['DB_NAME'] ?? 'wordpress');
define('DB_USER', $_ENV['DB_USER'] ?? 'root');
define('DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? '');
define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', '');
define('WP_ENVIRONMENT_TYPE', 'development');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('SCRIPT_DEBUG', true);
}
// Authentication keys and salts
define('AUTH_KEY', $_ENV['AUTH_KEY'] ?? 'put-your-unique-phrase-here');
define('SECURE_AUTH_KEY', $_ENV['SECURE_AUTH_KEY'] ?? 'put-your-unique-phrase-here');
define('LOGGED_IN_KEY', $_ENV['LOGGED_IN_KEY'] ?? 'put-your-unique-phrase-here');
define('NONCE_KEY', $_ENV['NONCE_KEY'] ?? 'put-your-unique-phrase-here');
define('AUTH_SALT', $_ENV['AUTH_SALT'] ?? 'put-your-unique-phrase-here');
define('SECURE_AUTH_SALT', $_ENV['SECURE_AUTH_SALT'] ?? 'put-your-unique-phrase-here');
define('LOGGED_IN_SALT', $_ENV['LOGGED_IN_SALT'] ?? 'put-your-unique-phrase-here');
define('NONCE_SALT', $_ENV['NONCE_SALT'] ?? 'put-your-unique-phrase-here');
// Table prefix
$table_prefix = $_ENV['DB_PREFIX'] ?? 'wp_';
// Content directory
define('WP_CONTENT_DIR', __DIR__ . '/wp-content');
define('WP_CONTENT_URL', ($_SERVER['HTTPS'] ?? '' === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . '/wp-content');
// WordPress home and site URL
define('WP_HOME', $_ENV['WP_HOME'] ?? 'http://localhost');
define('WP_SITEURL', $_ENV['WP_SITEURL'] ?? WP_HOME . '/wp');
// Absolute path to the WordPress directory
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/wp/');
}
require_once ABSPATH . 'wp-settings.php';
Your local .env file:
DB_NAME=wordpress
DB_USER=root
DB_PASSWORD=root
DB_HOST=127.0.0.1
DB_PREFIX=wp_
WP_HOME=https://mysite.lndo.site
WP_SITEURL=https://mysite.lndo.site/wp
AUTH_KEY='generate-me'
SECURE_AUTH_KEY='generate-me'
LOGGED_IN_KEY='generate-me'
NONCE_KEY='generate-me'
AUTH_SALT='generate-me'
SECURE_AUTH_SALT='generate-me'
LOGGED_IN_SALT='generate-me'
NONCE_SALT='generate-me'
Do not commit .env to version control. Add it to .gitignore.
Bedrock Environment Configuration
Bedrock has a more structured approach. Configuration lives in config/application.php:
<?php
/**
* Bedrock application configuration.
*/
use RootsWPConfigConfig;
use function Envenv;
$root_dir = dirname(__DIR__);
$webroot_dir = $root_dir . '/web';
/**
* Dotenv
*/
$dotenv = DotenvDotenv::createUnsafeImmutable($root_dir);
if (file_exists($root_dir . '/.env')) {
$dotenv->load();
$dotenv->required(['WP_HOME', 'WP_SITEURL']);
$dotenv->required(['DB_NAME', 'DB_USER', 'DB_PASSWORD']);
}
/**
* Set environment type
*/
$env_type = env('WP_ENVIRONMENT_TYPE') ?: 'production';
Config::define('WP_ENVIRONMENT_TYPE', $env_type);
/**
* URLs
*/
Config::define('WP_HOME', env('WP_HOME'));
Config::define('WP_SITEURL', env('WP_SITEURL'));
/**
* Custom Content Directory
*/
Config::define('CONTENT_DIR', '/app');
Config::define('WP_CONTENT_DIR', $webroot_dir . Config::get('CONTENT_DIR'));
Config::define('WP_CONTENT_URL', Config::get('WP_HOME') . Config::get('CONTENT_DIR'));
/**
* Database
*/
Config::define('DB_NAME', env('DB_NAME'));
Config::define('DB_USER', env('DB_USER'));
Config::define('DB_PASSWORD', env('DB_PASSWORD'));
Config::define('DB_HOST', env('DB_HOST') ?: 'localhost');
Config::define('DB_CHARSET', 'utf8mb4');
Config::define('DB_COLLATE', '');
$table_prefix = env('DB_PREFIX') ?: 'wp_';
/**
* Authentication Keys and Salts
*/
Config::define('AUTH_KEY', env('AUTH_KEY'));
Config::define('SECURE_AUTH_KEY', env('SECURE_AUTH_KEY'));
Config::define('LOGGED_IN_KEY', env('LOGGED_IN_KEY'));
Config::define('NONCE_KEY', env('NONCE_KEY'));
Config::define('AUTH_SALT', env('AUTH_SALT'));
Config::define('SECURE_AUTH_SALT', env('SECURE_AUTH_SALT'));
Config::define('LOGGED_IN_SALT', env('LOGGED_IN_SALT'));
Config::define('NONCE_SALT', env('NONCE_SALT'));
/**
* Custom Settings
*/
Config::define('AUTOMATIC_UPDATER_DISABLED', true);
Config::define('DISABLE_WP_CRON', env('DISABLE_WP_CRON') ?: false);
Config::define('DISALLOW_FILE_EDIT', true);
/**
* Debugging
*/
Config::define('WP_DEBUG_DISPLAY', false);
Config::define('WP_DEBUG_LOG', env('WP_DEBUG_LOG') ?: false);
Config::define('SCRIPT_DEBUG', false);
ini_set('display_errors', '0');
/**
* Load environment-specific config
*/
$env_config = __DIR__ . '/environments/' . $env_type . '.php';
if (file_exists($env_config)) {
require_once $env_config;
}
Config::apply();
And the environment-specific file at config/environments/development.php:
<?php
/**
* Development environment settings.
*/
use RootsWPConfigConfig;
Config::define('SAVEQUERIES', true);
Config::define('WP_DEBUG', true);
Config::define('WP_DEBUG_DISPLAY', true);
Config::define('WP_DEBUG_LOG', true);
Config::define('SCRIPT_DEBUG', true);
Config::define('DISALLOW_FILE_MODS', false);
ini_set('display_errors', '1');
For Pantheon specifically, you will also want config/environments/staging.php (for the test environment) and config/environments/production.php (for live).
Bedrock on Pantheon needs a .env.pantheon file or equivalent mechanism to read Pantheon’s environment variables. A common pattern is to create a config/environments/pantheon.php that maps Pantheon’s variables:
<?php
/**
* Pantheon-specific environment configuration.
*
* Loaded when PANTHEON_ENVIRONMENT is defined.
*/
use RootsWPConfigConfig;
if (!defined('PANTHEON_ENVIRONMENT')) {
return;
}
$pantheon_env = PANTHEON_ENVIRONMENT;
// Database credentials from Pantheon
Config::define('DB_NAME', $_ENV['DB_NAME']);
Config::define('DB_USER', $_ENV['DB_USER']);
Config::define('DB_PASSWORD', $_ENV['DB_PASSWORD']);
Config::define('DB_HOST', $_ENV['DB_HOST'] . ':' . $_ENV['DB_PORT']);
// URLs
$domain = $_SERVER['HTTP_HOST'] ?? $_ENV['PANTHEON_ENVIRONMENT']
. '-' . $_ENV['PANTHEON_SITE_NAME'] . '.pantheonsite.io';
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
Config::define('WP_HOME', $scheme . '://' . $domain);
Config::define('WP_SITEURL', $scheme . '://' . $domain . '/wp');
// Force SSL on live and test
if (in_array($pantheon_env, ['live', 'test'], true)) {
Config::define('FORCE_SSL_ADMIN', true);
}
// Redis
if (!empty($_ENV['CACHE_HOST'])) {
Config::define('WP_REDIS_HOST', $_ENV['CACHE_HOST']);
Config::define('WP_REDIS_PORT', $_ENV['CACHE_PORT']);
}
Deployment Workflow: Local to Live
Pantheon has a specific deployment model that differs from typical hosting. Understanding it is critical to avoiding data loss and deployment headaches.
The Pantheon Environment Model
Pantheon provides three permanent environments: Dev, Test, and Live. Code flows up; content flows down.
Local → Dev → Test → Live (code promotion)
Live → Test → Dev → Local (database/files sync)
Multidev environments are temporary branches. Each multidev gets its own URL, its own database, and its own filesystem. They are perfect for feature branches and QA.
Local Development
For local development, use Lando with the Pantheon recipe. Your .lando.yml:
name: my-wp-site
recipe: pantheon
config:
framework: wordpress_composer
site: my-wp-site
id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
php: "8.2"
composer_version: "2"
via: nginx
webroot: web
database: mariadb:10.4
xdebug: false
Start Lando and pull the database from Dev:
lando start
lando pull --database=dev --files=dev --code=none
The --code=none flag is important. You do not want to overwrite your local Git checkout with Pantheon’s built artifact. You only want the database and uploaded files.
Feature Branch Workflow
Create a feature branch locally:
git checkout -b feature/add-seo-plugin
Add your plugin:
composer require wpackagist-plugin/wordpress-seo:^21.0
Commit both composer.json and composer.lock:
git add composer.json composer.lock
git commit -m "Add Yoast SEO plugin v21"
Create a multidev environment on Pantheon:
terminus multidev:create my-wp-site.dev feature-seo
Push your branch:
git push origin feature/add-seo-plugin:feature-seo
If you are using Integrated Composer (Pantheon upstream), Pantheon will automatically run composer install and deploy. If you are using Bedrock with Build Tools, your CI pipeline will build and push the artifact.
Copy the database from Dev to your multidev for testing:
terminus env:clone-content my-wp-site.dev feature-seo --db-only --yes
Test on the multidev URL. When you are satisfied, merge to the main branch and let it flow through the standard promotion pipeline.
Promoting Through Environments
Merge your feature branch into master:
git checkout master
git merge feature/add-seo-plugin
git push origin master
This deploys to the Dev environment. Test there. Then promote to Test:
terminus env:deploy my-wp-site.test --note="Deploy SEO plugin"
Clone the Live database to Test for a realistic test:
terminus env:clone-content my-wp-site.live test --db-only --yes
Activate and configure the plugin on Test. Run your smoke tests. When everything checks out:
terminus env:deploy my-wp-site.live --note="Deploy SEO plugin"
Clear the caches:
terminus env:clear-cache my-wp-site.live
A Critical Note on SFTP Mode
Pantheon’s Dev environment can operate in Git mode or SFTP mode. For Composer-managed sites, you should almost always be in Git mode. SFTP mode allows direct file changes on the server, which bypasses your Composer workflow entirely. If someone installs a plugin via the WordPress admin on an SFTP-mode Dev environment, that plugin will not be in your composer.json. It will get wiped on the next Git push.
Lock it down:
terminus connection:set my-wp-site.dev git
If you need SFTP for one-off debugging, switch temporarily and switch back:
terminus connection:set my-wp-site.dev sftp
# ... do your debugging ...
terminus connection:set my-wp-site.dev git
Handling WordPress Core Updates
Core updates are one of the biggest wins of Composer management. No more clicking “Update” in the dashboard and hoping.
Pantheon Upstream
With the Pantheon upstream, core updates come through two channels.
First, you can update the johnpbloch/wordpress package directly:
composer update johnpbloch/wordpress
Second, Pantheon pushes upstream updates that may include core version bumps along with Pantheon-specific patches. Check for upstream updates:
terminus upstream:updates:list my-wp-site
Apply them:
terminus upstream:updates:apply my-wp-site.dev --accept-upstream
This merges the upstream changes into your Dev environment. If there are merge conflicts (usually in composer.lock), you will need to resolve them manually. We cover that in troubleshooting.
Bedrock
With Bedrock, core updates go through roots/wordpress:
composer update roots/wordpress
That is it. Commit the changes to composer.json and composer.lock, push to your CI pipeline, and deploy.
To lock to a specific version:
composer require roots/wordpress:6.4.2
To allow minor updates but not major:
composer require roots/wordpress:^6.4
Testing Core Updates Safely
Never update core directly on Dev and promote. Always use a multidev:
git checkout -b update/wordpress-6.4.3
composer update johnpbloch/wordpress
git add composer.json composer.lock
git commit -m "Update WordPress core to 6.4.3"
git push origin update/wordpress-6.4.3
Create the multidev:
terminus multidev:create my-wp-site.dev wp-update
Push the branch:
git push origin update/wordpress-6.4.3:wp-update
Clone the live database:
terminus env:clone-content my-wp-site.live wp-update --db-only --yes
Run your tests. Check every critical page. Check your forms. Check your REST API endpoints. Only after thorough testing should you merge and promote.
Troubleshooting Common Issues
Here is where the rubber meets the road. These are the problems that will waste your afternoon if you do not know the solutions.
Composer Lock Conflicts
This is the single most common issue. Two developers update different packages on different branches. Both modify composer.lock. Git shows a merge conflict in a 2,000-line JSON file.
Do not try to manually resolve composer.lock conflicts. You will break the lock file’s integrity hashes and cause worse problems downstream.
Instead:
# Accept the current branch's composer.json (manually merge if both changed)
# Then regenerate the lock file:
git checkout --theirs composer.lock
composer update --lock
Or, if both branches changed composer.json:
# 1. Resolve composer.json manually (merge both changes)
# 2. Delete the conflicted lock file
rm composer.lock
# 3. Regenerate it
composer install
# 4. Commit both files
git add composer.json composer.lock
git commit -m "Resolve Composer lock conflict"
The composer update --lock command regenerates the lock file based on the current composer.json without changing any package versions. If your composer.json is correct, this is the safest path.
For teams that hit this frequently, consider a workflow rule: only one person updates Composer dependencies at a time, on a dedicated branch. Serialize your dependency updates.
Autoloader Issues
Symptoms: “Class not found” errors, plugins that fail silently, or a white screen of death.
The Composer autoloader needs to be regenerated when you add new packages that use PSR-4 or classmap autoloading:
composer dump-autoload
On Pantheon with Integrated Composer, the platform runs composer install which includes autoloader generation. But if you are running locally and something is not loading, try:
composer dump-autoload -o
The -o flag generates an optimized autoloader using classmaps instead of PSR-4 lookups. This is faster and catches missing classes at build time.
For Bedrock specifically, the autoloader must be loaded early. Check that your web/wp-config.php includes:
require_once dirname(__DIR__) . '/vendor/autoload.php';
If this line is missing or in the wrong location, nothing will work.
MU-Plugin Loading Order
Must-use plugins in WordPress are loaded alphabetically by filename before regular plugins. When you install mu-plugins via Composer, they end up in subdirectories:
web/wp-content/mu-plugins/
├── wp-native-php-sessions/
│ └── pantheon-sessions.php
├── pantheon-advanced-page-cache/
│ └── pantheon-advanced-page-cache.php
└── bedrock-autoloader.php # Bedrock only
The problem: WordPress only auto-loads PHP files directly in the mu-plugins/ directory. It does not recurse into subdirectories. So Composer-installed mu-plugins in their own directories will not load unless you have a loader.
Bedrock solves this with roots/bedrock-autoloader. It provides a single file in the mu-plugins root that scans subdirectories and loads their main plugin files.
The Pantheon upstream handles this differently. It includes a custom mu-plugin loader. If you are missing one, you need to add it. Create web/wp-content/mu-plugins/loader.php:
<?php
/**
* Plugin Name: MU Plugin Loader
* Description: Loads mu-plugins installed via Composer from subdirectories.
*/
$mu_plugins = [
'wp-native-php-sessions/pantheon-sessions.php',
'pantheon-advanced-page-cache/pantheon-advanced-page-cache.php',
];
foreach ($mu_plugins as $plugin) {
$path = __DIR__ . '/' . $plugin;
if (file_exists($path)) {
require_once $path;
}
}
This file must be committed to your repository. It is not managed by Composer because it is the bridge between Composer and WordPress’s mu-plugin loading.
Memory Limits During Composer Install
Composer can be memory-hungry, especially with large dependency trees. If you see “Allowed memory size exhausted” errors:
# Local fix
COMPOSER_MEMORY_LIMIT=-1 composer install
# Pantheon fix: add to pantheon.yml
build_step_env:
COMPOSER_MEMORY_LIMIT: "-1"
“The Starter Upstream Has Updates” Warning
When Pantheon detects upstream changes, it shows a dashboard notification. For Composer-managed sites, these updates often touch composer.lock, which leads to conflicts.
Before applying upstream updates:
# Check what changed
terminus upstream:updates:list my-wp-site
# Create a multidev to test
terminus multidev:create my-wp-site.dev upstream-test
# Apply updates to the multidev
terminus upstream:updates:apply my-wp-site.upstream-test --accept-upstream
If the apply fails due to conflicts, you need to pull the upstream changes into a local branch and resolve manually:
git remote add upstream git://github.com/pantheon-systems/wordpress-composer-managed.git
git fetch upstream
git checkout -b upstream-merge
git merge upstream/main --no-commit
# Resolve conflicts in composer.json
rm composer.lock
composer install
git add .
git commit -m "Merge upstream updates"
Plugin Version Conflicts
Sometimes two plugins require different versions of the same dependency. Composer will refuse to install and show a dependency resolution error.
composer why-not some-package/name 2.0
This command shows which packages are blocking the installation. Often the fix is to update the blocking package first, or to use the --with flag for a coordinated update:
composer update package-a package-b --with-all-dependencies
CI/CD Integration
For Bedrock on Pantheon (or for teams that want more control over the build process even with the Pantheon upstream), CI/CD is essential.
GitHub Actions
Here is a production-ready GitHub Actions workflow for deploying a Composer-managed WordPress site to Pantheon:
name: Deploy to Pantheon
on:
push:
branches:
- main
- 'feature/**'
env:
PANTHEON_SITE: my-wp-site
PHP_VERSION: "8.2"
TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:v2, terminus
extensions: mbstring, xml, curl, zip
- name: Install Terminus
run: |
mkdir -p ~/terminus
curl -L https://github.com/pantheon-systems/terminus/releases/latest/download/terminus.phar -o ~/terminus/terminus
chmod +x ~/terminus/terminus
echo "$HOME/terminus" >> $GITHUB_PATH
- name: Authenticate Terminus
run: terminus auth:login --machine-token="${TERMINUS_TOKEN}"
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
- name: Run PHPCS
run: composer test
continue-on-error: false
- name: Determine target environment
id: env
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "target=dev" >> $GITHUB_OUTPUT
echo "branch=master" >> $GITHUB_OUTPUT
else
BRANCH_NAME="${GITHUB_REF#refs/heads/feature/}"
SAFE_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9]/-/g' | cut -c1-11)
echo "target=$SAFE_NAME" >> $GITHUB_OUTPUT
echo "branch=$SAFE_NAME" >> $GITHUB_OUTPUT
fi
- name: Configure Git
run: |
git config user.name "GitHub Actions"
git config user.email "[email protected]"
- name: Deploy to Pantheon
run: |
TARGET="${{ steps.env.outputs.target }}"
BRANCH="${{ steps.env.outputs.branch }}"
# Add Pantheon remote
REPO=$(terminus connection:info ${PANTHEON_SITE}.dev --field=git_url)
git remote add pantheon "$REPO" || git remote set-url pantheon "$REPO"
# Force add built files (normally gitignored)
git add -f vendor/ web/wp/ web/wp-content/plugins/ web/wp-content/themes/
# Commit the build
git commit -m "Build: ${{ github.sha }}" --allow-empty
# Push to Pantheon
git push pantheon HEAD:${BRANCH} --force
# Create multidev if needed (for feature branches)
if [[ "$TARGET" != "dev" ]]; then
terminus multidev:create ${PANTHEON_SITE}.dev ${TARGET} --yes 2>/dev/null || true
fi
- name: Clear cache
run: terminus env:clear-cache ${PANTHEON_SITE}.${{ steps.env.outputs.target }}
Important notes on this workflow. The fetch-depth: 0 ensures the full Git history is available, which Pantheon sometimes needs. The --force push is intentional because the Pantheon remote has a different commit history (build artifacts committed). The multidev creation uses || true because it may already exist.
CircleCI
If your team uses CircleCI, here is the equivalent configuration for .circleci/config.yml:
version: 2.1
orbs:
php: circleci/[email protected]
executors:
default:
docker:
- image: cimg/php:8.2
working_directory: ~/project
jobs:
build:
executor: default
steps:
- checkout
- restore_cache:
keys:
- composer-v1-{{ checksum "composer.lock" }}
- composer-v1-
- run:
name: Install Composer dependencies
command: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
- save_cache:
key: composer-v1-{{ checksum "composer.lock" }}
paths:
- vendor
- run:
name: Run tests
command: composer test
- persist_to_workspace:
root: ~/project
paths:
- .
deploy:
executor: default
steps:
- attach_workspace:
at: ~/project
- run:
name: Install Terminus
command: |
mkdir -p ~/terminus
curl -L https://github.com/pantheon-systems/terminus/releases/latest/download/terminus.phar -o ~/terminus/terminus
chmod +x ~/terminus/terminus
echo 'export PATH="$HOME/terminus:$PATH"' >> "$BASH_ENV"
- run:
name: Authenticate
command: terminus auth:login --machine-token="${TERMINUS_TOKEN}"
- run:
name: Deploy to Pantheon
command: |
git config user.name "CircleCI"
git config user.email "[email protected]"
REPO=$(terminus connection:info my-wp-site.dev --field=git_url)
git remote add pantheon "$REPO" || true
git add -f vendor/ web/wp/ web/wp-content/plugins/ web/wp-content/themes/
git commit -m "Build: ${CIRCLE_SHA1}" --allow-empty
git push pantheon HEAD:master --force
terminus env:clear-cache my-wp-site.dev
workflows:
build-deploy:
jobs:
- build
- deploy:
requires:
- build
filters:
branches:
only: main
Secrets Management
Both CI platforms need access to the Pantheon machine token and SSH keys. Store these as encrypted secrets:
GitHub Actions: Go to Repository Settings, then Secrets and Variables, then Actions. Add TERMINUS_TOKEN.
CircleCI: Go to Project Settings, then Environment Variables. Add TERMINUS_TOKEN.
For SSH-based Git pushes, you also need to add an SSH key. Generate a dedicated deploy key:
ssh-keygen -t ed25519 -C "ci-deploy@my-wp-site" -f deploy_key -N ""
Add the public key to Pantheon via the dashboard (Account, then SSH Keys). Add the private key to your CI platform as a secret.
Managing Private Plugins and Themes
Not everything lives on WordPress.org. Premium plugins, custom internal plugins, and proprietary themes need special handling.
Option 1: Private Packagist or Satis
Private Packagist is a hosted Composer repository. You upload your premium plugin ZIPs, and it serves them as Composer packages.
{
"repositories": [
{
"type": "composer",
"url": "https://repo.packagist.com/your-org/"
},
{
"type": "composer",
"url": "https://wpackagist.org",
"only": [
"wpackagist-plugin/*",
"wpackagist-theme/*"
]
}
]
}
Authenticate via auth.json (not committed to version control):
{
"http-basic": {
"repo.packagist.com": {
"username": "token",
"password": "your-private-packagist-token"
}
}
}
For CI, set the token as an environment variable:
composer config --global --auth http-basic.repo.packagist.com token "$PACKAGIST_TOKEN"
If you do not want to pay for Private Packagist, you can self-host Satis, which is a free static Composer repository generator.
Option 2: GitHub/GitLab Repository
If your premium plugin is in a private Git repository, add it directly:
{
"repositories": [
{
"type": "vcs",
"url": "[email protected]:your-org/premium-plugin.git"
}
],
"require": {
"your-org/premium-plugin": "^2.0"
}
}
The plugin repository needs a composer.json at its root:
{
"name": "your-org/premium-plugin",
"type": "wordpress-plugin",
"require": {
"composer/installers": "^2.0"
}
}
The "type": "wordpress-plugin" line is what tells Composer Installers to place it in the plugins directory instead of the vendor directory.
Option 3: Artifact Repository (ZIP files)
For plugins distributed only as ZIPs (like Gravity Forms), you can create an artifact repository. This is a directory of ZIP files that Composer treats as a package source.
Create a directory structure:
private-plugins/
├── gravityforms-2.7.14.zip
└── wp-migrate-db-pro-2.6.8.zip
Each ZIP must contain a composer.json at its root. If the plugin does not ship with one, you need to add it before zipping.
Add to your composer.json:
{
"repositories": [
{
"type": "artifact",
"url": "private-plugins/"
}
]
}
This approach is the least elegant but works for plugins where you have no other option. The downside is that you must manually download new ZIP versions and update the artifact directory.
Option 4: SatisPress
SatisPress is a WordPress plugin that turns your existing WordPress installation into a Composer repository. Install it on a separate WordPress instance (a “package server”), install all your premium plugins there with valid licenses, and SatisPress exposes them as Composer packages.
This is clever because it leverages the plugins’ built-in update mechanisms. When Gravity Forms releases an update, your SatisPress server picks it up automatically. Your project’s Composer workflow then pulls from SatisPress.
Add the SatisPress repository to your composer.json:
{
"repositories": [
{
"type": "composer",
"url": "https://your-satispress-server.com/satispress/"
}
]
}
Database Sync Strategies
Code lives in Git. The database does not. Syncing databases across Pantheon environments is a daily task, and getting it wrong means lost content, broken URLs, or worse.
Basic Sync with Terminus
Clone the live database down to dev:
terminus env:clone-content my-wp-site.live dev --db-only --yes
Clone live to a multidev:
terminus env:clone-content my-wp-site.live feature-seo --db-only --yes
These commands create a complete copy of the database. All posts, pages, users, options, and plugin data come along. This is usually what you want for development and testing.
Local Database Sync
To get the database locally with Lando:
lando pull --database=live --files=none --code=none
Without Lando, use Terminus and WP-CLI:
# Export from Pantheon
terminus wp my-wp-site.live -- db export - | gzip > live-db.sql.gz
# Import locally
gunzip -c live-db.sql.gz | wp db import -
After importing, run a search-replace to fix URLs:
wp search-replace 'https://www.mysite.com' 'https://mysite.lndo.site' --all-tables
Always use the --all-tables flag. WordPress stores URLs in the options table, post content, post meta, and sometimes in plugin-specific tables. Missing one table means broken links.
Partial Syncs and Targeted Exports
Full database syncs are heavy. If you only need specific data, use WP-CLI’s export and import:
# Export only posts
terminus wp my-wp-site.live -- export --post_type=post --dir=/tmp
terminus rsync my-wp-site.live:/tmp/export.xml ./
# Import locally
wp import export.xml --authors=mapping.csv
For plugin-specific data, you might need to export individual tables:
# Export specific tables
terminus wp my-wp-site.live -- db export - --tables=wp_woocommerce_orders,wp_woocommerce_order_items | gzip > woo-orders.sql.gz
Database Migration Safety Rules
Rule one: never clone a lower environment’s database up to a higher environment. Never push your dev database to live. Content flows down, code flows up. This is not a suggestion; it is a hard rule on Pantheon, and for good reason.
Rule two: always back up before cloning. Terminus makes this easy:
terminus backup:create my-wp-site.dev --element=db
terminus backup:create my-wp-site.test --element=db
Rule three: after cloning a database from live to a lower environment, flush the object cache and rewrite rules:
terminus wp my-wp-site.dev -- cache flush
terminus wp my-wp-site.dev -- rewrite flush
Rule four: if you have WooCommerce or any e-commerce plugin, be extremely careful with database syncs. Live order data in a dev environment can trigger duplicate emails, duplicate webhook calls, and duplicate payment processing if your payment gateway is in live mode on dev. Disable outbound email and switch payment gateways to test mode immediately after syncing.
Automating Syncs with Quicksilver
Pantheon’s Quicksilver hooks let you run scripts automatically after certain events. You can use them to run post-deploy tasks, including database-related cleanup.
In your pantheon.yml:
workflows:
sync_code:
after:
- type: webphp
description: Flush caches after code sync
script: private/scripts/quicksilver/flush-cache.php
clone_database:
after:
- type: webphp
description: Run search-replace after database clone
script: private/scripts/quicksilver/search-replace.php
The flush-cache.php script:
<?php
/**
* Quicksilver script: Flush caches after code deploy.
*/
// Flush WordPress object cache
if (function_exists('wp_cache_flush')) {
wp_cache_flush();
}
// Clear Pantheon edge cache
if (function_exists('pantheon_clear_edge_all')) {
pantheon_clear_edge_all();
}
echo "Caches flushed.n";
Advanced Configuration Topics
Composer Scripts and Hooks
Composer scripts let you run custom code at various points in the dependency management lifecycle. For WordPress projects, common uses include generating .htaccess rules, running database migrations, and clearing caches.
{
"scripts": {
"post-install-cmd": [
"@php -r "if (!file_exists('.env')) copy('.env.example', '.env');""
],
"post-update-cmd": [
"@composer dump-autoload -o"
],
"test": [
"phpcs --standard=phpcs.xml"
],
"lint": [
"phpcs -s --colors"
],
"lint:fix": [
"phpcbf"
]
}
}
The post-install-cmd hook runs after composer install. The post-update-cmd hook runs after composer update. These are useful for automating repetitive setup tasks.
Requiring Specific PHP Extensions
If your project depends on specific PHP extensions, declare them in composer.json:
{
"require": {
"ext-mbstring": "*",
"ext-xml": "*",
"ext-curl": "*",
"ext-zip": "*",
"ext-gd": "*"
}
}
Composer will verify these extensions are available during install. This catches environment mismatches early, before you discover them as runtime errors in production.
The platform Config
The config.platform setting in composer.json tells Composer to pretend you are running a specific PHP version, regardless of what is actually installed locally:
{
"config": {
"platform": {
"php": "8.2.0"
}
}
}
This is critical for Pantheon deployments. If your local machine runs PHP 8.3 but Pantheon runs 8.2, Composer might resolve dependencies that require 8.3 features. The platform config prevents this mismatch.
Always set this to match your Pantheon PHP version. Check your current Pantheon PHP version:
terminus env:info my-wp-site.dev --field=php_version
Handling wp-config.php for Multidev
Multidev environments have unique names that affect URLs and sometimes configuration. Your wp-config should handle this dynamically:
if (defined('PANTHEON_ENVIRONMENT') && !in_array(PANTHEON_ENVIRONMENT, ['dev', 'test', 'live'])) {
// This is a multidev environment
$multidev_name = PANTHEON_ENVIRONMENT;
// Multidevs may need specific configuration
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
}
Object Cache with Redis
Pantheon offers Redis on paid plans. To enable it with Composer:
composer require wpackagist-plugin/wp-redis
Then, create or update the wp-content/object-cache.php drop-in. The WP Redis plugin includes this file, but for Composer-managed sites, you may need to symlink or copy it:
{
"scripts": {
"post-install-cmd": [
"test -f web/wp-content/object-cache.php || cp web/wp-content/plugins/wp-redis/object-cache.php web/wp-content/object-cache.php"
]
}
}
Enable Redis on Pantheon:
terminus redis:enable my-wp-site
Verify it is working:
terminus wp my-wp-site.dev -- redis status
Git Workflow Best Practices
What to Commit, What to Ignore
Your .gitignore for a Pantheon Composer-managed site should include:
# Dependencies (built by Composer)
/vendor/
/web/wp/
/web/wp-content/plugins/*/
/web/wp-content/themes/*/
# But DO commit your custom theme
!/web/wp-content/themes/my-theme/
# Environment files
.env
.env.local
# OS files
.DS_Store
Thumbs.db
# IDE files
.idea/
.vscode/
*.swp
# Node (if your theme uses build tools)
node_modules/
web/wp-content/themes/my-theme/dist/
# WordPress uploads (synced via Pantheon)
web/wp-content/uploads/
# Lando
.lando.local.yml
Notice that vendor/, web/wp/, and plugin/theme directories are ignored. Integrated Composer on Pantheon rebuilds these on every push. For CI/CD workflows with Bedrock, your build step adds them back before pushing the artifact to Pantheon.
Always commit composer.lock. This is your guarantee of reproducible builds. If someone says “just add it to .gitignore,” they are wrong. The lock file is the single source of truth for your dependency versions.
Branch Naming Conventions
Pantheon multidev environment names have restrictions: 11 characters max, alphanumeric and hyphens only. Plan your branch naming accordingly:
# Good: short, descriptive, Pantheon-compatible
feature/seo → multidev: seo
feature/redis → multidev: redis
fix/login-bug → multidev: login-bug
update/wp-core → multidev: wp-core
# Bad: too long for multidev
feature/implement-full-seo-strategy → ???
Map your Git branches to multidev names in your CI pipeline. The GitHub Actions example above includes this mapping.
Commit Message Discipline
For Composer changes, be specific in your commit messages:
# Good
git commit -m "Add Yoast SEO plugin v21.4"
git commit -m "Update WordPress core from 6.4.1 to 6.4.3"
git commit -m "Remove unused Classic Editor plugin"
# Bad
git commit -m "Update packages"
git commit -m "Composer stuff"
git commit -m "Fix"
When someone needs to debug why a specific plugin version is deployed, they should be able to trace it through the Git log without reading diffs.
Performance Considerations
Optimizing Composer Install
On CI/CD pipelines, composer install can be slow. Speed it up:
# Use --prefer-dist to download ZIP archives instead of cloning repos
composer install --prefer-dist
# Use --no-dev to skip development dependencies in production
composer install --no-dev
# Use --optimize-autoloader for faster class loading
composer install --optimize-autoloader
# Combine them all
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
Cache your Composer dependencies in CI. Both GitHub Actions and CircleCI support caching the ~/.composer/cache directory. This can cut install times from minutes to seconds on repeat builds.
Reducing Build Size
The vendor/ directory can be large. For Integrated Composer on Pantheon, this is less of a concern because the build happens server-side. But for CI/CD artifact-based deployments, consider:
# Remove unnecessary files from packages
{
"config": {
"optimize-autoloader": true
}
}
Some teams use composer-plugin-api tools to strip tests, docs, and other non-essential files from vendor packages. The bamarni/composer-bin-plugin is one option for separating development tools from production dependencies.
Pantheon Edge Caching
After deploying, always clear the edge cache. Pantheon’s CDN aggressively caches pages, and stale cache after a deployment causes confusion:
terminus env:clear-cache my-wp-site.live
The pantheon-advanced-page-cache plugin provides granular cache invalidation based on WordPress actions. Install it via Composer (we included it in the example composer.json above) and configure it through the WordPress admin.
Real-World Workflow: Putting It All Together
Let me walk through a complete, realistic workflow. You are adding WooCommerce to an existing Pantheon Composer-managed site.
Step 1: Create a Feature Branch
git checkout -b feature/woocommerce
Step 2: Add WooCommerce and Required Plugins
composer require wpackagist-plugin/woocommerce:^8.4
composer require wpackagist-plugin/woocommerce-gateway-stripe:^7.8
Step 3: Commit
git add composer.json composer.lock
git commit -m "Add WooCommerce 8.4 and Stripe gateway 7.8"
Step 4: Create Multidev and Deploy
terminus multidev:create my-wp-site.dev woocommerce
git push origin feature/woocommerce:woocommerce
Step 5: Sync Live Database
terminus env:clone-content my-wp-site.live woocommerce --db-only --yes
Step 6: Activate and Configure
terminus wp my-wp-site.woocommerce -- plugin activate woocommerce
terminus wp my-wp-site.woocommerce -- plugin activate woocommerce-gateway-stripe
Visit the multidev URL and run through the WooCommerce setup wizard. Configure the Stripe gateway in test mode. Create test products. Run test orders.
Step 7: QA Sign-off
Share the multidev URL with your QA team or client. They test on an environment that mirrors production (same database, same content) with the new code.
Step 8: Merge and Promote
git checkout master
git merge feature/woocommerce
git push origin master
Wait for the Dev environment to build. Then promote:
terminus env:deploy my-wp-site.test --note="Add WooCommerce"
terminus env:clone-content my-wp-site.live test --db-only --yes
Test on Test with live data. Then go live:
terminus env:deploy my-wp-site.live --note="Add WooCommerce"
terminus wp my-wp-site.live -- plugin activate woocommerce
terminus wp my-wp-site.live -- plugin activate woocommerce-gateway-stripe
terminus env:clear-cache my-wp-site.live
Step 9: Clean Up
terminus multidev:delete my-wp-site.woocommerce --delete-branch --yes
git branch -d feature/woocommerce
git push origin --delete woocommerce
That is the complete cycle. Branch, build, test, promote, clean up. Every step is traceable. Every dependency is version-locked. Every environment is reproducible.
Migration: Moving an Existing Site to Composer Management
If you have an existing Pantheon WordPress site that uses the traditional (non-Composer) upstream, migrating to Composer management requires careful planning.
Audit Your Current Plugins and Themes
First, list everything:
terminus wp my-wp-site.live -- plugin list --format=csv
terminus wp my-wp-site.live -- theme list --format=csv
For each plugin, determine if it is available on WPackagist, has its own Composer repository, or needs to be handled as an artifact.
Create a spreadsheet tracking each plugin, its current version, its Composer package name, and its source. You will reference this during migration.
Create the New Composer Structure
Start fresh. Create a new local project from the wordpress-composer-managed template:
composer create-project pantheon-systems/wordpress-composer-managed my-wp-site-composer
cd my-wp-site-composer
Add your plugins one by one:
composer require wpackagist-plugin/advanced-custom-fields:^6.2
composer require wpackagist-plugin/wordpress-seo:^21.0
# ... repeat for each plugin
Handle Custom Themes
Copy your custom theme into the appropriate directory:
cp -r /path/to/old-site/wp-content/themes/my-theme web/wp-content/themes/my-theme
Make sure the theme is not in .gitignore. Custom themes are committed to your repository; they are not Composer dependencies (unless you choose to package them that way).
Handle Custom Plugins
If you have custom plugins written specifically for this site, treat them like custom themes: copy them in and commit them. Add an exclusion in .gitignore:
!/web/wp-content/plugins/my-custom-plugin/
Test with Live Data
Create the new site on Pantheon, import the live database, and test thoroughly. Every page. Every form. Every integration. Plugin activation order can matter, especially for plugins that hook into each other.
DNS Cutover
Once testing is complete, point your domain to the new site. Pantheon provides documentation for DNS configuration. The critical thing is to minimize downtime by having everything tested and ready before the switch.
Final Thoughts
Running WordPress with Composer on Pantheon is more work upfront than the traditional “download and unzip” approach. There is no pretending otherwise. You need to understand Composer, Git workflows, environment configuration, and CI/CD pipelines.
But the payoff is enormous. Every dependency is tracked. Every build is reproducible. Every deployment is predictable. You can onboard a new developer and they can spin up an identical local environment in minutes. You can roll back a bad deployment with a single Git revert. You can audit exactly which version of every plugin is running in production at any given moment.
The Pantheon wordpress-composer-managed upstream is the easier starting point. Use it if you are already committed to Pantheon and want minimal CI/CD overhead. Bedrock is the more flexible option. Use it if you value platform independence and a cleaner configuration model.
Either way, once you make the switch, you will not go back to managing WordPress plugins by hand. The mental overhead of tracking ZIP files, FTP uploads, and “did someone update that plugin on production without telling anyone” disappears. Your WordPress infrastructure becomes as disciplined as any modern application stack.
Start with a small site. Get comfortable with the workflow. Then roll it out to your entire portfolio. Your future self, staring at a 3 AM production incident, will thank you for having a reproducible, version-controlled, Composer-managed WordPress stack.
Tom Bradley
DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.