Skip to content

Latest commit

 

History

History
247 lines (186 loc) · 8.86 KB

File metadata and controls

247 lines (186 loc) · 8.86 KB

Getting started

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.

1. Install the scoper

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

Composer 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.lock like everything else, install it as a dev dependency instead — see Configuration.

2. Create the plugin

mkdir my-plugin && cd my-plugin

3. Declare the dependencies to scope

Scoped 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 require and composer wpify-scoper remove instead — see step 9.

4. Configure the prefix

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 folder as well — to vendor-prefixed. WordPress.org runs every new submission through Plugin Check, which skips vendor-prefixed/ but scans the default deps/, 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 .gitignore in step 8 both change with it. See Publishing to WordPress.org.

5. Run it

composer install

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

6. Check the result

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.php
namespace 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 makes composer install reproducible for everyone else on the project.
  • a tmp-* directory, if the run failed. A successful run removes its workspace. See Troubleshooting.

7. Load it from your plugin

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.

8. Decide what to commit

/vendor/
/tmp-*
  • composer-deps.json — commit. It is your source of truth.
  • composer-deps.lock — commit. Without it, composer install is 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.

9. Add and remove dependencies later

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/guzzle

Each 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 the repositories and the config.platform.php it declares. That is why monolog/monolog with 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.

Where to go next

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