Managing WordPress entirely through Composer

I published turkerdev/wp-composer in August: a project template for a WordPress install where core, themes and plugins are all Composer dependencies and the repository contains a lock file rather than forty thousand files. This is the arrangement I have been running for years and had never written down properly.

The symptom

$ git ls-files | wc -l
41,208

$ git ls-files wp-admin wp-includes | wc -l
2,104

$ git log --oneline -1 --stat -- wp-includes | tail -3
 1,882 files changed, 41,204 insertions(+), 38,102 deletions(-)

# a core update, as a commit. unreviewable by
# construction, and the plugin updates are the same
# shape.

A core update as a diff of nearly two thousand files is not a thing anybody reviews, so it gets merged on trust. That is fine and it means version control is being used as a file transfer mechanism rather than as a record of decisions.

Why it happens

WordPress predates dependency management and ships as a directory you extract. Every convention in the ecosystem — the updater, the plugin installer, the directory layout — assumes the files are the truth, and Composer assumes the manifest is.

The fix

The pieces

{
  "repositories": [
    { "type": "composer", "url": "https://wpackagist.org", "only": [
      "wpackagist-plugin/*", "wpackagist-theme/*"
    ]}
  ],
  "require": {
    "php": "^8.3",
    "johnpbloch/wordpress": "^6.8",
    "wpackagist-plugin/wordpress-seo": "^24.0",
    "wpackagist-plugin/redirection": "^5.5",
    "composer/installers": "^2.3"
  },
  "extra": {
    "wordpress-install-dir": "public/wp",
    "installer-paths": {
      "public/app/plugins/{$name}/": ["type:wordpress-plugin"],
      "public/app/themes/{$name}/": ["type:wordpress-theme"],
      "public/app/mu-plugins/{$name}/": ["type:wordpress-muplugin"]
    }
  }
}

The only constraint on the wpackagist repository is the detail worth copying — without it Composer queries a mirror of the entire plugin directory for every package resolution, which is slow and means a typo in a package name can resolve to somebody else’s plugin.

The directory layout, and why wp-content moves

  public/
    wp/          core, installed by composer, never
                 edited, safe to delete and reinstall
    app/         themes, plugins, mu-plugins, uploads
    index.php    two lines
    wp-config.php
  vendor/
  composer.json
  composer.lock  ← the only file describing the site

core cannot contain wp-content, because core is a
directory Composer owns and will replace wholesale on
every update. anything inside it is transient.
// public/wp-config.php — before wp-settings.php loads
define( 'WP_HOME', rtrim( getenv( 'WP_HOME' ), '/' ) );
define( 'WP_SITEURL', WP_HOME . '/wp' );

define( 'WP_CONTENT_DIR', __DIR__ . '/app' );
define( 'WP_CONTENT_URL', WP_HOME . '/app' );

require_once __DIR__ . '/wp/wp-settings.php';
// public/index.php
<?php
define( 'WP_USE_THEMES', true );
require __DIR__ . '/wp/wp-blog-header.php';

Four constants and a two-line front controller is the whole structural change, and the ordering matters — WP_CONTENT_DIR has to be defined before wp-settings.php runs or core computes it from its own location and finds nothing.

Premium plugins with no repository

four commercial plugins, distributed as a zip behind a
licence key. three honest options:

  1  an inline package definition with a versioned
     download URL. the version must be bumped by hand,
     because there is no feed to discover releases
     from.

  2  a private Composer repository — Satis or
     Private Packagist — which somebody has to run and
     which turns four zips into infrastructure.

  3  commit the zip, or the extracted directory,
     which works and puts a licensed binary in version
     control.

we use (1). the template documents all three, because
(2) is right above about a dozen plugins.
{
  "type": "package",
  "package": {
    "name": "vendor/premium-plugin",
    "version": "4.2.1",
    "type": "wordpress-plugin",
    "dist": {
      "type": "zip",
      "url": "https://vendor.example/download/4.2.1?key={$VENDOR_KEY}"
    }
  }
}

The licence key comes from the environment rather than the file, which means the manifest is committable and an install without the key fails loudly rather than silently fetching nothing. The hand-bumped version is the genuine cost and it is one line every few months.

Disabling the admin updater

define( 'DISALLOW_FILE_MODS', true );
add_filter( 'automatic_updater_disabled', '__return_true' );

add_action( 'admin_notices', function () {
    if ( ! current_user_can( 'update_plugins' ) ) {
        return;
    }

    printf(
        '<div class="notice notice-info"><p>%s</p></div>',
        esc_html__(
            'Plugins and core are managed with Composer. Updates are made in the repository and deployed.',
            'turkerdev'
        )
    );
} );
what DISALLOW_FILE_MODS removes:

  the plugin and theme installers
  the update screens
  the file editor
  automatic background updates, INCLUDING security
    ones — which is the trade, and it means the
    pipeline is now responsible for security releases

what that requires: a scheduled composer update, a
review, and a deploy. weekly, in our case.

Turning off automatic security updates is the part of this arrangement that has to be stated plainly, because WordPress ships them for good reasons. The weekly update job with an audit step is what replaces it, and a site where nobody runs that job is worse off than one using the built-in updater.

The update flow

$ composer update --dry-run 2>&1 | grep Upgrading
  Upgrading wpackagist-plugin/wordpress-seo (24.1 => 24.2)
  Upgrading johnpbloch/wordpress-core (6.8.1 => 6.8.2)

$ composer update
$ git diff --stat
 composer.lock | 22 +++++++++++-----------

# a core security release, as an eleven-line diff.
# reviewable, attributable, and revertable.

An eleven-line diff against a nineteen-hundred-file one is the whole argument, and it is not about the size — it is that the diff now describes the decision rather than its consequences. A reviewer sees which packages moved and to what, which is the information a review of a dependency update actually needs.

Rolling back

$ composer require wpackagist-plugin/some-plugin:4.1.2 
    --update-with-dependencies
$ git commit -m 'pin some-plugin to 4.1.2 — checkout bug, VENDOR-88'
$ git push

# and the deploy is the same deploy as always.
# no file restore, no database, no SFTP.

What breaks

  the admin's own updater      deliberately, and it
                               confuses people
  one-click plugin install     gone. a plugin is a
                               pull request.
  a plugin that writes to its
    own directory              8 of ~200 do this.
                               composer overwrites it
                               on the next update.
                               → the directory must be
                                 gitignored and
                                 persisted separately,
                                 or the plugin is
                                 unusable this way.
  a plugin whose installer
    runs during activation     fine, as long as it
                               writes to uploads.
  a client without the
    pipeline                   cannot maintain the site
                               at all.

The plugins that write to their own directory are the genuine incompatibility and there are about eight of them in common use — caching plugins generating a configuration file, and form builders storing uploads next to their code. Both are fixable with a filter and both require knowing about it before the first update wipes the file.

Verifying it worked

$ rm -rf public/wp public/app/plugins vendor
$ composer install
$ ./bin/smoke-test
  22 checks passed
# a clean install from the lock file, from nothing

$ git ls-files | wc -l
188            # was 41,208

$ composer audit --locked
  No security vulnerability advisories found

$ ./bin/wp-checksums
  core files verified against wordpress.org: OK
  # the checksum check still passes, because core is
  # unmodified — which it can now be guaranteed to be

Deleting the entire install and rebuilding it from the lock file is the assertion that the manifest is the truth, and it takes ninety seconds. The core checksum verification passing is the secondary benefit — a core directory that Composer owns cannot have been edited by anybody, which was never true before.

What this costs

A workflow nobody else on a WordPress project expects. Every developer, every agency and every client who touches this site has to learn that the admin is read-only for anything structural, and the notice explaining it is read by the people who already know.

It also makes the pipeline responsible for security updates, which WordPress otherwise handles automatically and well. A weekly job with a review is a better arrangement than automatic updates when somebody runs it and a considerably worse one when nobody does — and the failure mode is silent, because the site keeps working while falling behind.

The eight plugins that write to their own directory are also a permanent hazard. Each needs a filter or a persisted volume, the incompatibility is not documented by the plugin authors, and the way you find out is that a setting disappears after an update — which is exactly the failure mode this arrangement was supposed to eliminate.