veronalabs / wp-scoper
A Composer plugin that prefixes namespaces in WordPress plugin dependencies to prevent conflicts
Installs: 124
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 0
Forks: 0
Open Issues: 0
Type:composer-plugin
pkg:composer/veronalabs/wp-scoper
Requires
- php: ^7.4|^8.0
- composer-plugin-api: ^2.0
- symfony/console: ^5.4|^6.0|^7.0
- symfony/filesystem: ^5.4|^6.0|^7.0
- symfony/finder: ^5.4|^6.0|^7.0
Requires (Dev)
- composer/composer: ^2.0
- phpunit/phpunit: ^9.6|^10.0
README
A Composer plugin that prefixes namespaces in WordPress plugin dependencies to prevent fatal PHP conflicts.
The Problem
WordPress plugins sharing the same Composer dependencies (e.g., Guzzle, GeoIP2) cause fatal errors because PHP cannot load two versions of the same class. When Plugin A requires geoip2/geoip2 ^2.0 and Plugin B requires geoip2/geoip2 ^3.0, one will crash.
WP Scoper solves this by adding a unique prefix to all namespaces in your vendored dependencies:
GeoIp2\Database\Reader → WP_Statistics\Deps\GeoIp2\Database\Reader
Each plugin gets its own isolated copy of dependencies. No conflicts.
Comparison
| Feature | WP Scoper | Mozart | PHP-Scoper |
|---|---|---|---|
| Installation | composer require --dev |
Global / PHAR | Global / PHAR |
| Works on any machine | ✅ Yes | ❌ No | ❌ No |
| Composer plugin | ✅ Yes (auto-runs) | ❌ No | ❌ No |
| Namespace prefixing | ✅ Yes | ✅ Yes | ✅ Yes |
| Global class prefixing | ✅ Yes | ✅ Yes | ✅ Yes |
| Constant prefixing | ✅ Yes | ❌ No | ✅ Yes |
| Template/view safety | ✅ Auto-detect + config | ❌ No | ⚠️ Manual exclude |
| WordPress function safety | ✅ Safe | ✅ Safe | ❌ Breaks WP functions |
| Property name bug | ✅ Fixed | ❌ Had bugs | ➖ N/A (AST) |
| Autoloader generation | ✅ Yes (classmap) | ❌ No | ⚠️ Partial |
| Dev-dependency support | ✅ Yes | ❌ No | ❌ No |
| Update host source files | ✅ Yes | ❌ No | ➖ N/A |
| PHP version support | 7.4+ | 8.1+ | 7.2+ |
Installation
composer require --dev veronalabs/wp-scoper
Add configuration to your composer.json:
{
"require": {
"geoip2/geoip2": "^2.0"
},
"require-dev": {
"veronalabs/wp-scoper": "^1.0"
},
"extra": {
"wp-scoper": {
"namespace_prefix": "WP_Statistics\\Deps",
"packages": ["geoip2/geoip2"]
}
}
}
Run composer install or composer wp-scope. That's it.
Output
After running, WP Scoper displays a summary of what was done:
+------------------------------------------------+
| WP Scoper - Your dependencies, your namespace! |
+---------------------+--------------------------+
| Packages | 7 |
| PHP Files Prefixed | 90 |
| Files Excluded | 60 |
| Namespaces Prefixed | 6 |
| Global Classes | 3 |
| Constants | 0 |
| Call Sites Updated | 0 |
| Output Size | 2.3 MB / 2.7 MB (-14%) |
| Target Directory | packages |
+------------------------------------------------+
| Stat | Description |
|---|---|
| Packages | Number of vendor packages processed (including transitive dependencies) |
| PHP Files Prefixed | PHP files copied and namespace-prefixed |
| Files Excluded | Files skipped by built-in and custom exclude patterns (tests, docs, configs) |
| Namespaces Prefixed | Unique vendor namespaces that were rewritten |
| Global Classes | Non-namespaced classes that were prefixed (e.g. Spyc -> WP_Statistics_Spyc) |
| Constants | define() constants that were prefixed |
| Call Sites Updated | Files in your src/ whose use statements were auto-updated |
| Output Size | Output size vs original size with reduction percentage |
| Target Directory | Where prefixed files were written |
How It Works
- Reads your
extra.wp-scoperconfig fromcomposer.json - Discovers listed packages and their transitive dependencies
- Copies them to a target directory (default:
vendor-prefixed/) - Prefixes all namespaces, global classes, and constants
- Generates a classmap-based autoloader
- Optionally updates
usestatements in your own source files
Configuration Reference
| Option | Required | Default | Description |
|---|---|---|---|
namespace_prefix |
Yes | - | Prefix for all namespaces (e.g., WP_Statistics\\Deps) |
packages |
Yes | - | Which vendor packages to prefix (transitive deps auto-included) |
target_directory |
No | vendor-prefixed |
Where prefixed files are copied |
class_prefix |
No | Auto-derived | Prefix for global (non-namespaced) classes |
constant_prefix |
No | Auto-derived | Prefix for define() constants |
exclude_packages |
No | [] |
Transitive deps to skip |
exclude_patterns |
No | [] |
Additional regex patterns for files to skip (merged with built-in patterns) |
exclude_directories |
No | ["views", "templates", "resources"] |
Directories with template files (copied but not prefixed) |
delete_vendor_packages |
No | false |
Remove originals from vendor/ after copy |
update_call_sites |
No | true |
Update use statements in host project files. true scans src/, or pass an array of directories (see below) |
dev_packages |
No | null |
Config for prefixing require-dev packages separately |
Full Configuration Example
{
"extra": {
"wp-scoper": {
"target_directory": "packages",
"namespace_prefix": "WP_Statistics\\Deps",
"class_prefix": "WP_Statistics_",
"constant_prefix": "WP_STATISTICS_",
"packages": ["geoip2/geoip2", "matomo/device-detector"],
"exclude_packages": ["psr/log"],
"exclude_patterns": ["/\\.md$/", "/tests?\\//i"],
"exclude_directories": ["views", "templates", "resources"],
"delete_vendor_packages": false,
"update_call_sites": true,
"dev_packages": {
"enabled": true,
"target_directory": "tests/vendor-prefixed",
"packages": ["fakerphp/faker"]
}
}
}
}
Project Layout
Recommended layout with vendor/ gitignored:
my-plugin/
├── .gitignore # /vendor/ is gitignored
├── composer.json
├── vendor/ # NOT committed
│ └── autoload.php
├── packages/ # COMMITTED - prefixed vendor packages
│ ├── autoload.php # Generated by wp-scoper
│ ├── geoip2/
│ └── maxmind/
├── src/
│ └── Plugin.php # Your code
└── tests/
In your plugin's main file:
require_once __DIR__ . '/packages/autoload.php'; use WP_Statistics\Deps\GeoIp2\Database\Reader; $reader = new Reader('/path/to/GeoLite2-City.mmdb');
Usage
As a Composer Plugin (automatic)
WP Scoper runs automatically after composer install and composer update.
Manual Command
composer wp-scope composer wp-scope --dry-run
Standalone CLI
vendor/bin/wp-scoper vendor/bin/wp-scoper /path/to/project vendor/bin/wp-scoper --dry-run
Automatic Call Site Updates (update_call_sites)
When dependencies get prefixed, your own source code still references the original namespaces. Normally you'd have to manually find and replace every use statement, every new call, and every type hint across your entire project. With update_call_sites enabled (the default), wp-scoper does this for you automatically.
By default it scans all PHP files in your src/ directory. You can also pass an array of directories to scan additional locations:
{
"update_call_sites": ["src", "includes"]
}
You write your code using the original package namespaces, and wp-scoper handles the rest.
Example: Before and After
Say you have this file in your project:
src/GeoIP/GeoIPService.php - before running wp-scoper:
<?php namespace WP_Statistics\GeoIP; use GeoIp2\Database\Reader; use GeoIp2\Model\City; use GeoIp2\Exception\AddressNotFoundException; class GeoIPService { /** @var Reader */ private $reader; public function __construct(string $dbPath) { $this->reader = new Reader($dbPath); } public function lookup(string $ip): ?City { try { return $this->reader->city($ip); } catch (AddressNotFoundException $e) { return null; } } }
After running composer install (or composer wp-scope), wp-scoper automatically changes it to:
<?php namespace WP_Statistics\GeoIP; use WP_Statistics\Deps\GeoIp2\Database\Reader; use WP_Statistics\Deps\GeoIp2\Model\City; use WP_Statistics\Deps\GeoIp2\Exception\AddressNotFoundException; class GeoIPService { /** @var Reader */ private $reader; public function __construct(string $dbPath) { $this->reader = new Reader($dbPath); } public function lookup(string $ip): ?City { try { return $this->reader->city($ip); } catch (AddressNotFoundException $e) { return null; } } }
Notice:
- All three
usestatements were updated automatically - Your own namespace (
WP_Statistics\GeoIP) was not touched $this->readerwas not touched- Local aliases (
Reader,City,AddressNotFoundException) still work as before - You never had to manually search/replace anything
What gets updated in your source files
use GeoIp2\...→use WP_Statistics\Deps\GeoIp2\...new \GeoIp2\Database\Reader()→new \WP_Statistics\Deps\GeoIp2\Database\Reader()@param GeoIp2\Model\City→@param WP_Statistics\Deps\GeoIp2\Model\City- Fully qualified class references, type hints, catch blocks, etc.
When to disable it
Set "update_call_sites": false if:
- You prefer to manage namespace references manually
- Your source files are outside
src/and you handle updates yourself - You're running wp-scoper in a CI pipeline where source files shouldn't be modified
Template Safety
WP Scoper auto-detects template/view files (HTML mixed with PHP) and skips them during prefixing. Detection uses:
- Directory names - Files in
views/,templates/,resources/(configurable) - Content analysis - Files not starting with
<?phpthat contain HTML tags - HTML-to-PHP ratio - Files with significantly more HTML than PHP
Template files are still copied to the target directory; they just aren't modified.
What Gets Prefixed
- Namespace declarations:
namespace GeoIp2;→namespace WP_Statistics\Deps\GeoIp2; - Use statements:
use GeoIp2\Database\Reader;→use WP_Statistics\Deps\GeoIp2\Database\Reader; - Fully qualified names:
new \GeoIp2\Database\Reader()→new \WP_Statistics\Deps\GeoIp2\Database\Reader() - Type hints and return types
- String class references:
'GeoIp2\Database\Reader' - PHPDoc annotations:
@param GeoIp2\Model\City - Global classes:
class MyHelper→class WPS_MyHelper - Constants:
define('MY_CONST', ...)→define('WPS_MY_CONST', ...)
What Does NOT Get Prefixed
- Property access:
$this->readeris never touched - Variable names:
$readeris never touched - Array keys:
$data['reader']is never touched - WordPress functions:
add_action,get_option, etc. are safe - PHP built-in classes:
stdClass,DateTime,Exception, etc. - PHP built-in constants:
PHP_EOL,DIRECTORY_SEPARATOR, etc. - Files matching
exclude_patterns - Template/view files (auto-detected)
Dev-Dependencies Support
Prefix require-dev packages into a separate directory for test isolation:
{
"dev_packages": {
"enabled": true,
"target_directory": "tests/vendor-prefixed",
"packages": ["fakerphp/faker"]
}
}
This generates a separate autoloader at tests/vendor-prefixed/autoload.php loaded only during testing.
Standalone Autoloader (No vendor/ in Production)
WordPress plugins submitted to wordpress.org can't ship a vendor/ directory. WP Scoper solves this by generating a standalone autoloader that handles both your own classes and prefixed dependencies — no vendor/autoload.php needed.
WP Scoper automatically reads your project's autoload.psr-4 from composer.json and includes it in the generated autoloader. For example, with this config:
{
"autoload": {
"psr-4": {
"WP_SMS\\": "src/"
}
},
"extra": {
"wp-scoper": {
"namespace_prefix": "WP_SMS\\Deps",
"packages": ["geoip2/geoip2"],
"target_directory": "packages"
}
}
}
The generated packages/autoload.php will autoload:
- Prefixed dependencies via classmap (
WP_SMS\Deps\GeoIp2\...) - Your own classes via PSR-4 (
WP_SMS\...→src/)
In your plugin, you only need one require:
require_once __DIR__ . '/packages/autoload.php';
This single file replaces vendor/autoload.php entirely. Ship packages/ in your plugin zip, keep vendor/ in .gitignore.
Requirements
- PHP 7.4+
- Composer 2.0+
License
MIT