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 beDeleting 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.