Thanks for helping out.
This document covers the mechanics of contributing and the two things that are not obvious from the
code: what sources/ is, and how the symbol lists are produced. For how the tool actually works —
the pipeline, the fixups, the swap — read docs/how-it-works.md first.
Everyone taking part is expected to follow the Code of Conduct.
Something is broken. Check docs/troubleshooting.md, then open an
issue using the bug report form. A report without the -v output and your extra.wpify-scoper
block cannot be acted on, which is why the form asks for both.
A WordPress symbol came out prefixed (or a symbol that should have been scoped did not). Use the Missing or wrongly scoped symbol form. This is the worst failure mode this project has and it gets its own template.
A security issue. Not in a public issue — see SECURITY.md.
You want to change behaviour. Open an issue before writing the code. Anything that changes what
lands in a consumer's deps/ needs a conversation first, because consumers cannot see that their
scoped tree changed until something fatals in production.
A typo, a doc fix, an obviously-correct bug fix. Just send the pull request.
git clone https://github.com/wpify/scoper.git
cd scoper
composer installcomposer install downloads about 160 MB into sources/. That is expected — see below.
require-dev is not test tooling. Most of it is the input data for symbol extraction:
| Package | What it is for |
|---|---|
johnpbloch/wordpress |
WordPress core, installed into sources/wordpress |
wpackagist-plugin/woocommerce |
WooCommerce, installed into sources/plugin-woocommerce |
woocommerce/action-scheduler |
Action Scheduler, installed into sources/plugin-action-scheduler |
wp-cli/wp-cli |
WP-CLI, read from vendor/wp-cli/wp-cli |
nikic/php-parser |
Parses all of the above |
jetbrains/phpstorm-stubs |
Editor support while working on the extractor |
The install locations come from extra.wordpress-install-dir and extra.installer-paths in
composer.json. sources/ and vendor/ are both git-ignored and never ship.
None of this reaches consumers: Composer ignores a dependency's require-dev, repositories,
config, minimum-stability and scripts. The only sections of this package's composer.json
that affect a consumer are require and extra.class.
The rest of require-dev — PHPUnit, PHPStan, PHP-CS-Fixer — is what you would expect.
symbols/*.php is the point of this package: the database of names that must not be moved into
the consumer's prefix, because WordPress declares them and the consumer's plugin calls them.
They are generated. Do not edit them by hand — the header says so, and the next regeneration would discard your change.
composer update # pull the current WordPress / WooCommerce / Action Scheduler / WP-CLI
composer extract # rewrite symbols/*.php from themcomposer extract exits non-zero if any source file failed to parse. Take that seriously: a parse
failure drops symbols, a symbol missing from the WordPress list gets scoped, and the consumer's
site then fatals on a call to an undefined function. That is the worst failure this project has.
Before opening a regeneration pull request, check the counts did not collapse:
php scripts/symbol-guard.php snapshot /tmp/before.json
composer extract
php scripts/symbol-guard.php compare /tmp/before.json 1.0The scheduled Refresh symbols workflow does exactly this every Monday and refuses to open a pull
request when a count drops by more than 1%.
symbols/*.php is marked linguist-generated in .gitattributes, so GitHub collapses the diff.
Review the counts, not the lines.
scripts/SymbolExtractor.php walks every PHP file in a source tree and records what it declares.
It is a full AST traversal rather than a top-level scan, because WordPress declares symbols in
every shape PHP allows: classes behind class_exists() guards, classes in else branches,
define() calls several levels inside a function body, class_alias() targets that exist nowhere
else. Files under vendor/, wp-content/, tests/, spec/, features/ and .github/ are
skipped — their symbols are never loaded in a WordPress request, so excluding them would only stop
the consumer's own dependencies from using those names.
scripts/extract-symbols.php is a thin CLI over that class. Everything under scripts/ is
development-only and is export-ignored from dist archives.
composer test # unit + golden-file, milliseconds
composer test:integration # end-to-end, spawns Composer and php-scoper
composer test:allThree tiers:
-
tests/Unit/— pure logic.ConfigurationTestandScoperConfigFactoryTestcover the config surface;SymbolUnprefixerTestcovers the patcher that decides which symbols come back out of the prefix, which is where a mistake silently breaks somebody's production site. -
The golden-file test —
SymbolExtractorTestruns the extractor overtests/fixtures/symbols-input/and compares the rendered output totests/fixtures/symbols-expected.php. When you change the extractor deliberately:UPDATE_SNAPSHOTS=1 vendor/bin/phpunit --filter golden
Then read the diff. That diff is the whole value of the test.
-
tests/Integration/— marked#[Group('integration')]and excluded from the default suite. Runs the real pipeline againsttests/fixtures/e2e/, a self-contained path repository, and asserts on the scoped bytes. It never touches the network:packagist.orgis disabled in the fixture's manifest.
If you add a fixture package, keep it offline. A test that resolves from Packagist is a test that fails on a train.
composer analyse # PHPStan level 9 + phpstan-strict-rules, no baseline
composer cs # check
composer cs:fix # fixThere is no baseline, and there should not be one. If PHPStan finds something, it found something.
The codebase is written in a WordPress-ish dialect: tabs, a space inside every bracket
(function foo( $bar ) {), array() rather than [], Yoda comparisons. That is unusual for a
Composer plugin, whose reviewers come from the PSR world and whose entire dependency surface
(composer/composer, symfony/console) is PSR-12.
.php-cs-fixer.dist.php enforces the style that is actually there rather than replacing it. It
covers imports, whitespace, casing, Yoda comparisons and docblocks, and deliberately leaves brace
placement, bracket spacing and array syntax alone, because PHP-CS-Fixer cannot express the
WordPress variants of those rules.
What a PSR-12 migration would cost, if anyone wants to do it: essentially every line of every
PHP file is reformatted — bracket spacing alone touches almost all of them. git blame on src/
becomes useless unless the migration commit is added to .git-blame-ignore-revs, and every open
branch conflicts. The offsetting argument is that PHP-CS-Fixer's @PHP82Migration ruleset performs
the array() → [] conversion and several other modernisations mechanically, so the reformat
commit and the modernise commit can be the same commit — you pay the blame cost once. If you do
it: one isolated commit, no logic changes, and add the SHA to .git-blame-ignore-revs in the same
pull request.
User documentation lives in docs/, and the README is the front door — a pitch, a quickstart and a
link table, nothing more. If you change user-facing behaviour, update the page that documents it in
the same pull request.
The pages have distinct jobs and it is worth keeping them that way:
| Page | Job |
|---|---|
docs/getting-started.md |
A tutorial. One path, no choices, verifiable at each step. |
docs/configuration.md |
A reference. Complete and boring. |
docs/troubleshooting.md |
Symptom, cause, fix. |
docs/deployment.md |
Task recipes. |
docs/customizing.md |
Task recipes for scoper.custom.php. |
docs/how-it-works.md |
Explanation. Why, not how-to. |
docs/upgrading-to-4.md |
Where historical behaviour belongs. |
Two conventions worth respecting:
- Keep history out of the reference pages. "This used to be a silent no-op" belongs in the changelog or the upgrade guide, not in the page a new user reads as current behaviour.
- Use the same word for the same thing. Scoped tree is the output;
deps/names the path. Workspace is thetmp-*directory. Customization file isscoper.custom.php. Scoped manifest iscomposer-deps.json. User docs say "your project", never "consumer" — that word is for this file.
A link check runs on every pull request, so relative links that break during a rename are caught before merge.
Branch from master. Keep the pull request to one thing.
composer validate --strict
composer analyse
composer cs
composer test
composer test:integrationCI runs all of it across PHP 8.2, 8.3, 8.4 and 8.5, plus a smoke job that installs the plugin into
a scratch project — the only tier that catches "the plugin throws during activate()".
Commit messages follow the shape already in the log: a short imperative summary, and a body explaining why when the why is not obvious. Nothing stricter than that is enforced.
Anything that changes the generated output is at least a minor release, not a patch, even when the
diff is three characters. Consumers cannot see that their scoped tree changed until something
fatals in production. Say so in CHANGELOG.md, under Changed or Fixed, and say what
changed about the output rather than what changed in the code.
Releases are manual. composer.json carries no version field on purpose — Packagist derives the
version from the tag.
-
Move the
[Unreleased]heading inCHANGELOG.mdto the version and date, and update the comparison links at the bottom of the file. -
Confirm CI is green on
master. -
Tag and push:
git tag -a 4.0.0 -m "4.0.0" git push origin 4.0.0 -
Packagist picks the tag up from the repository webhook.
Version numbers follow Semantic Versioning, with the rule above layered on top: a change to the generated scoped output is never a patch.