Back to Blog
Platform Guides

Pantheon Composer-Managed WordPress with Bedrock: The Definitive Setup and Workflow Guide

Tom Bradley
43 min read

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_HOST
  • PANTHEON_ENVIRONMENT (dev, test, live, or the multidev name)
  • PANTHEON_SITE
  • CACHE_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.

Share this article

Tom Bradley

DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.