By the end of this page you will have a WordPress plugin whose copy of Guzzle lives in a namespace no other plugin can reach, and you will have verified that on disk rather than taken it on trust.
Guzzle is the example because it is the collision people actually hit. It is bundled by a large
number of WordPress plugins, its major versions are not source-compatible, and only one copy can
own the GuzzleHttp\Client name in a running site.
You need PHP 8.2 or newer and Composer 2.6 or newer.
The scoper is a Composer plugin. Install it globally, pinned to a major version:
composer global config --no-plugins allow-plugins.wpify/scoper true
composer global require wpify/scoper:^4.0Composer refuses to run plugins it has not been told to trust, and it does so silently — the
allow-plugins line above is not optional, and skipping it produces a composer install that
appears to succeed and scopes nothing.
Pin the same constraint everywhere. The scoper version that produced your scoped tree is not recorded in any lock file. A laptop on 3.2 and a CI runner on 4.0 can emit different bytes from identical sources. Use the same constraint on every machine and in CI, and re-scope after you upgrade the scoper. If you would rather have it pinned in
composer.locklike everything else, install it as a dev dependency instead — see Configuration.
mkdir my-plugin && cd my-pluginScoped dependencies do not go in composer.json. They go in a second manifest, composer-deps.json,
which has exactly the same format:
{
"require": {
"guzzlehttp/guzzle": "^7.0"
},
"config": {
"platform": {
"php": "8.2.0"
}
}
}The split is the whole idea. composer-deps.json holds what gets prefixed; composer.json holds
everything else — your dev tooling, your test framework, anything that never runs inside a
WordPress request alongside another plugin.
config.platform.php should match the PHP version your site runs, not the one your laptop
runs. Composer resolves against it, so setting it lower than production is how you end up with a
scoped tree that fatals on the server.
You write this file by hand once. After the scoper is configured you can add and remove dependencies with
composer wpify-scoper requireandcomposer wpify-scoper removeinstead — see step 9.
In composer.json:
{
"config": {
"allow-plugins": {
"wpify/scoper": true
},
"platform": {
"php": "8.2.0"
}
},
"extra": {
"wpify-scoper": {
"prefix": "MyPlugin\\Deps"
}
}
}prefix is the only required setting. It must be a valid PHP namespace — identifiers separated by
backslashes, no leading or trailing separator, and remember that JSON needs each backslash doubled.
Everything else has a default; see Configuration.
Pick something nobody else will: your plugin's own vendor namespace with \Deps on the end is a
good default. Deps on its own is not — two plugins that both chose it collide exactly the way
this tool exists to prevent.
If this plugin is going to WordPress.org, set
folderas well — tovendor-prefixed. WordPress.org runs every new submission through Plugin Check, which skipsvendor-prefixed/but scans the defaultdeps/, and reports your scoped libraries as if you had written them. Set it now rather than moving the tree later: the autoloader path in step 7 and the.gitignorein step 8 both change with it. See Publishing to WordPress.org.
composer installYou will see the scoper announce itself:
wpify-scoper: running composer install for /path/to/my-plugin/composer-deps.json,
scoping it with the prefix MyPlugin\Deps into /path/to/my-plugin/deps
Behind that line it resolves composer-deps.json in a temporary workspace, rewrites the result
with php-scoper, and moves the finished tree into deps/. If nothing at all is printed and no
deps/ folder appears, the plugin is not allowed to run — go back to step 1.
Three things should now exist:
ls deps/autoload.php composer/ guzzlehttp/ psr/ scoper-autoload.php
Confirm the prefix actually landed:
grep -r "namespace MyPlugin" deps/guzzlehttp/guzzle/src/Client.phpnamespace MyPlugin\Deps\GuzzleHttp;And confirm WordPress was left alone. There is no WordPress in this example yet, but the rule is
worth seeing now: any call to add_action(), WP_Query, wp_remote_get() and the thousands of
other names WordPress declares stays exactly as written. Only your dependencies move.
Two files were also written next to your manifest:
composer-deps.lock— the lock file for the scoped set. Commit it. It is what makescomposer installreproducible for everyone else on the project.- a
tmp-*directory, if the run failed. A successful run removes its workspace. See Troubleshooting.
Create my-plugin.php:
<?php
/**
* Plugin Name: My Plugin
*/
require_once __DIR__ . '/deps/scoper-autoload.php';
require_once __DIR__ . '/vendor/autoload.php';
add_action( 'init', function () {
$client = new \MyPlugin\Deps\GuzzleHttp\Client();
// ...
} );Two autoloaders, in that order. deps/scoper-autoload.php pulls in deps/autoload.php itself, so
you only ever require the one file — but you still need vendor/autoload.php for your own
unscoped code.
Note that add_action() is called unprefixed. That is the point of the symbol lists: WordPress's
own names are excluded from the rewrite, so your plugin talks to WordPress normally while its
dependencies live somewhere private.
If you set folder in step 4, require the autoloader from there instead — for
"folder": "vendor-prefixed" that is
require_once __DIR__ . '/vendor-prefixed/scoper-autoload.php';. Everything else is unchanged.
/vendor/
/tmp-*composer-deps.json— commit. It is your source of truth.composer-deps.lock— commit. Without it,composer installis not reproducible.deps/— your call. It is a build artifact, so most projects build it in CI and ignore it. Commit it if you deploy by pushing a git checkout to a server where you cannot run Composer.tmp-*— never. Always ignore it.
If you set folder to vendor-prefixed in step 4, ignore that path instead of /deps/. It is a
sibling of /vendor/ rather than a child, so it needs its own line:
/vendor/
/vendor-prefixed/
/tmp-*Deployment covers the trade-off properly.
You wrote composer-deps.json by hand in step 3 because there was nothing configured yet to write
it for you. Now that there is, you do not have to do it again:
composer wpify-scoper require monolog/monolog
composer wpify-scoper remove guzzlehttp/guzzleEach one resolves the dependency, updates composer-deps.json and composer-deps.lock, and
rebuilds deps/. There is no second command to run afterwards.
Two things worth knowing:
- The constraint is resolved against
composer-deps.json, using therepositoriesand theconfig.platform.phpit declares. That is whymonolog/monologwith no version gets a constraint that will actually install on your site rather than on your laptop. - Your file is edited surgically. Only the entry you named changes; your key order, your formatting and every other block are left exactly as they were. And nothing is written at all until the run has succeeded, so a constraint that cannot be satisfied leaves you where you started.
Use --dev to put the dependency in require-dev, and --dry-run to see what Composer would
resolve without writing anything. Configuration
has the full list.
Note that these are for your scoped dependencies only. Plain composer require still manages
composer.json — the split from step 3 does not go away.
- Configuration — change the output folder, drop symbol lists you do not need, turn off automatic scoping.
- Publishing to WordPress.org — why the scoped tree belongs in
vendor/, what Plugin Check does and does not look at, and the checklist to run before you submit. - Deployment — GitLab CI, GitHub Actions, Bedrock, multi-plugin repositories.
- Customizing php-scoper — when a dependency builds class names dynamically and php-scoper cannot see them.
- How it works — what the symbol lists are and how the pipeline is put together.