cleanplugins / scoper
Scope Composer dependencies and build a production package tree without collisions.
Requires
- php: >=8.3
- composer-plugin-api: ^2.0
- ext-json: *
- ext-tokenizer: *
- ext-zip: *
- nikic/php-parser: ^5.9
- symfony/console: ^7.2
- symfony/process: ^7.2
Requires (Dev)
- composer/composer: ^2.10
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 20:24:03 UTC
README
Scoper builds a self-contained production tree for a Composer project:
- installs production dependencies from the project lock file;
- copies and stages the project's own files;
- removes configured documentation, binaries, packages, paths and string literals;
- prefixes every dependency namespace and global symbol so two plugins on the same site cannot collide;
- generates a classmap autoloader from Composer's own installation metadata;
- verifies the staged tree in a clean PHP process;
- optionally creates a release zip.
It is a Composer plugin and a standalone CLI. The build is designed for CI: it is deterministic, reports every phase, and fails loudly when the tree is not safe to ship.
Why Scoper
Most scopers parse and reprint every PHP file. Scoper replaces only the name byte ranges that need renaming.
- line numbers, blank lines, comments, CRLF endings, indentation and formatting stay unchanged;
- a scoped file differs from its source only where a symbol was renamed;
- stack traces and
__LINE__values still map to the original source; - the result stays reviewable and diffs cleanly in CI.
Requirements
- PHP 8.3 or newer
- Composer 2
ext-json,ext-tokenizer,ext-zip- Node/npm only if the project config declares an asset command
Installation
Install the latest stable release:
composer require --dev cleanplugins/scoper
Install the development branch directly from GitHub:
composer config repositories.scoper vcs https://github.com/cleanplugins/scoper.git composer require --dev cleanplugins/scoper:dev-main
Allow the plugin when Composer asks, or add it to the project manifest:
{
"config": {
"allow-plugins": {
"cleanplugins/scoper": true
}
}
}
The package is a development dependency. A release build runs composer install --no-dev, so the scoper is never part of the shipped tree.
Quick start
Add the build configuration to the project's composer.json:
{
"name": "acme/example-plugin",
"require": {
"acme/library": "^1.0"
},
"extra": {
"scoper": {
"prefix": "Acme\\Build",
"source": ["plugin.php", "includes", "assets"],
"vendor": "vendor",
"dist": "assets",
"output": "build",
"artifacts": "var/scoper",
"releases": "releases"
}
}
}
Build the staged tree:
composer scoper:build
Create the release zip as well:
composer scoper:build --release
The standalone binary is available without Composer commands:
vendor/bin/scoper scoper:build vendor/bin/scoper scoper:build --release
Command options
| Option | Description |
|---|---|
--release |
Also write releases/<release.name>-<version>.zip. |
--no-assets |
Reuse the existing dist directory instead of running the asset command. |
--stream |
Stream asset and Composer output to the terminal as it runs. |
-v |
Print the aligned detail sections for every phase. |
-q |
Only print the phase summaries and the final result. |
--no-ansi |
Disable colors and animated progress. |
--working-dir |
Project directory for the standalone binary; defaults to the current directory. |
On a TTY, phases with a known total show a real progress bar; other phases show a spinner with elapsed time. Redirected output, -q, --no-ansi and CI suppress both.
How it works
- Prerequisites — validates the release guards and reads the project version.
- Clean output — empties the configured output directory.
- Assets — optionally runs the configured asset command and checks freshness.
- Dependencies — installs production dependencies into the output directory from the project lock file.
- Stage source files — copies the configured source entries.
- Removals — applies the
removepolicy before scoping. - Scope namespaces — inventories declarations, splices only the name byte ranges, verifies syntax and survivors, then applies the edits. Files are never reprinted.
- Generate autoloader — writes a classmap loader for the scoped tree.
- Verify tree — loads the staged tree in a clean PHP process and runs the configured probes.
- Finalise — removes Composer metadata and VCS files.
- Package release — optionally writes the release zip.
The shipper never contains composer.json or composer.lock. Composer's own autoloader is not shipped either; the generated classmap is the only loader.
Configuration
All settings live under extra.scoper. Other locations, including the retired extra.build key, are rejected.
| Key | Default | Description |
|---|---|---|
prefix |
required | Namespace prefix or object with namespace and optional global styles. |
source |
required | Files and directories copied into the output tree. |
vendor |
config.vendor-dir or vendor |
Project vendor directory. |
dist |
dist |
Built asset directory checked or copied by the build. |
output |
build |
Staged production tree. |
artifacts |
.build |
Build metadata: prefix map, installed package list, log and verify config. |
releases |
releases |
Release zip directory. |
assets.command |
null |
String or list of shell commands run before staging. |
assets.sources |
[] |
Files and directories whose modification time is compared with dist. |
assets.check |
warn |
off, warn or fail. |
composer.args |
[] |
Extra arguments appended to composer install. |
verify.probes |
null |
PHP file returning per-package probe callables. |
verify.own |
autoload roots | Project roots that must remain byte-identical while their imports are scoped. |
release.name |
composer package name | Base name of the release zip. |
release.plugin_file |
null |
File whose WordPress Version header supplies the shipped version. |
All paths are relative to the project and must not escape it. source may not overlap output or artifacts, and artifacts may not sit inside output.
Prefix
prefix may be a namespace string:
{ "prefix": "Acme\\Build" }
or an object with a namespace and global styles:
{
"prefix": {
"namespace": "Acme\\Build",
"global": {
"base": "Acme Build",
"snake_pascal": "Acme_Build_"
}
}
}
The available global styles are screaming_snake, snake_pascal, snake_case, pascal and camel. An explicit style wins; otherwise the style is derived from global.base, then from the namespace. The namespace style also decides how global symbols are renamed.
Policy
ignore, remove and override are lists of entries. Every entry needs type, value and reason:
{
"extra": {
"scoper": {
"prefix": "Acme\\Build",
"source": ["plugin.php", "includes"],
"ignore": [
{
"type": "package",
"value": "symfony/polyfill-*",
"reason": "Keep PHP extension fallback names global."
},
{
"type": "string",
"value": "Legacy_",
"package": "acme/library",
"reason": "Introspection literal; the package depends on the exact prefix."
}
],
"remove": [
{
"type": "path",
"value": "vendor/**/*.md",
"exclude": "license*",
"reason": "Documentation is not needed at runtime; licences must ship."
}
]
}
}
}
Supported types:
ignore:package,string,namespace,path,globalremove:package,string,pathoverride:namespace,global
A package field qualifies a string or path entry. Package names may wildcard the name, for example symfony/polyfill-*, but never the vendor. exclude on a removed path matches file names only.
Glob semantics:
*matches one path segment.**matches any characters.**/matches zero or more whole segments.- Matching is case-insensitive.
- A pattern naming a directory covers everything below it.
override wins over ignore; a package-qualified entry wins over an unqualified one; the longest namespace match wins. Duplicate decisions and entries that match nothing are reported.
Release version
By default, the archive uses the top-level version from composer.json. To read the version from a plugin file instead:
{
"release": {
"name": "acme-plugin",
"plugin_file": "plugin.php"
}
}
Scoper reads only the first 8 KB of plugin_file, normalizes CR line endings, and extracts the first non-empty WordPress Version header. A file without that header fails the release. The header name is fixed as Version; constants and custom header names are not supported.
The archive is named <name>-<version>.zip. If neither plugin_file nor a composer version is available, no version suffix is added.
Set SCOPER_ALLOW_DIRTY=1 to package uncommitted changes for a local snapshot. Without it, --release requires a clean Git working tree.
Assets
The asset command runs in the project root:
{
"assets": {
"command": "npm run build",
"sources": ["src", "vite.config.js"],
"check": "warn"
}
}
When --no-assets is used, the command is skipped and the existing dist is reused. The freshness check compares the newest configured source against the newest dist file.
Probes
Probes call the scoped code after it has been loaded in a clean process. A probe file returns a map of package name to callable:
<?php return [ 'acme/library' => static function (array $config): string { $class = $config['prefix'] . '\\Acme\\Library\\Compiler'; if (!class_exists($class)) { throw new RuntimeException("{$class} did not autoload"); } return 'Compiler class loads'; }, ];
The callable receives the normalized scoper configuration. Probes run only for packages present in the staged tree.
CI example
- uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: zip - run: composer install --no-interaction --no-progress - run: composer scoper:build --release
Use --no-ansi or redirect output when the CI log should not contain progress sequences.
Development
composer install
composer test
composer test:coverage
The test suite has unit tests and fixture-based integration tests under tests/. Build tests use temporary directories and do not modify the repository.
The package is split into these areas:
Config— manifest loading, policy and prefix resolution.Build— the phase pipeline, version reading and release packaging.Prune,Scope,Autoload,Verify— the build stages.Console— commands, progress reporting and the shared runner.Support— filesystem, path, process and formatting helpers.
License
MIT. See LICENSE.