siratik / package-loader
Version coordinator for bundled WordPress packages. Ensures the newest copy of a shared package wins when several plugins vendor their own.
Requires
- php: >=8.1
Requires (Dev)
- builtnorth/coding-standards: ^1.0
- phpunit/phpunit: ^9.6
- yoast/phpunit-polyfills: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 22:39:38 UTC
README
Version coordinator for WordPress packages that get bundled by more than one plugin.
The problem
WordPress plugins each ship their own vendor/ directory. When two plugins bundle the same
package at different versions, PHP can only ever have one copy of a given class in a request —
and Composer's autoloader is not version-aware. Whichever copy resolves first wins, which in
practice comes down to plugin load order. An older copy silently winning means missing fixes,
missing methods, and bugs that reproduce even after an update.
Plugin authors cannot coordinate with each other, and they shouldn't have to.
What this does
Packages that depend on siratik/package-loader get a shared registry. Each bundled copy
registers itself with its version; the registry puts its own autoloader at the front of the SPL
chain and resolves classes from the newest registered copy, regardless of load order.
It follows Action Scheduler's model (ActionScheduler_Versions):
- Every copy registers. Composer runs a
filesentry once per id, and the id (md5("<package>:register.php")) is the same in every plugin'svendor/. The registry clears that id on each registration, so the next plugin's copy runs too. - The newest wins once
plugins_loadedfires. By then every plugin file, and so every bundled copy, has loaded. A class loaded earlier locks its package to the newest copy registered so far, and the registry reports it through_doing_it_wrong()(shown withWP_DEBUGon). - Themes never outrank plugins. Themes load after
plugins_loaded. A theme's copy is used only when no plugin bundles that package; otherwise it is ignored, even if newer. - Copies of the same version register once. The first one wins.
There is nothing for a consuming plugin author to do. This package is a transitive dependency,
its bootstrap runs from Composer's files autoload, and registration happens automatically when
vendor/autoload.php is required.
For package authors
Add the dependency and a version.php at your package root:
<?php // version.php return '1.4.2';
Then register from a files-autoloaded bootstrap:
<?php // register.php — listed under autoload.files in composer.json use Siratik\PackageLoader\Registry; if (class_exists(Registry::class, false)) { Registry::instance()->register([ 'package' => 'acme/widgets', 'version' => (string) require __DIR__ . '/version.php', 'root' => __DIR__, 'psr4' => ['Acme\\Widgets\\' => 'inc/classes/'], ]); }
package must be the package's Composer name exactly; the registry uses it to find the
files id. Keep this file at your package root, outside your PSR-4 root. A registration file reachable through your own
package's namespace prefix makes the coordinator resolve a class name back onto it.
Booting is not this package's job
There is deliberately no boot or init callback. Your package is started by whoever uses it, and
that already gets the right version: when a consumer calls \Acme\Widgets\App::init(), PHP
resolves that class name through this autoloader, which hands back the newest registered copy.
Controlling resolution is controlling the boot.
The one thing to avoid is touching package classes at plugin-file-load time, before every
bundling plugin has registered — the first copy to resolve is pinned for the request, and the
registry raises _doing_it_wrong(). Use any hook (plugins_loaded, init, anything later) and
all copies will have registered by then. A theme can use package classes as soon as its
functions.php runs, since plugins_loaded has already fired.
Global functions
Composer's files autoload is eager, and function declarations are first-wins — the opposite of
the newest-wins resolution this package gives classes. Keep function bodies as one-line
forwarders to a class, so it never matters which copy declared them:
if (! function_exists('acme_widgets_render')) { function acme_widgets_render(...$args) { return \Acme\Widgets\API::render(...$args); } }
Always guard declarations with function_exists. An unguarded declaration is a fatal redeclare
error the moment a second plugin bundles your package.
Running before plugins_loaded
Code that must run before regular plugins load (an mu-plugin — a firewall, for example) can still get the newest copies by registering every active plugin's copy first:
// In the mu-plugin, from your own bundled copy: require WP_PLUGIN_DIR . '/my-plugin/vendor/siratik/package-loader/preflight.php'; if (siratik_package_loader_preflight()) { // Package classes now resolve exactly as they will after plugins_loaded. }
siratik_package_loader_preflight() reads the active plugins (active_plugins, plus
active_sitewide_plugins on multisite), loads the newest package-loader copy among them, then
includes each bundled package's register.php, found through the plugin's
vendor/composer/installed.php. Those are data files only; no plugin code runs. When the plugins
load later, their registrations are ignored as already seen. Early use of a preflighted package
raises no _doing_it_wrong().
It returns false when an older Registry without preflight() already owns the request; don't
use package classes early in that case.
Trade-offs, the same ones Jetpack's autoloader makes: a plugin activated during the request joins on the next one, and a plugin WordPress has paused in recovery mode is still counted.
Diagnostics
$registry = \Siratik\PackageLoader\Registry::instance(); $registry->version('acme/widgets'); // version that won this request $registry->candidates('acme/widgets'); // every registered copy: root path => version
Caveats
A class loaded before every bundler has registered pins that version for the request. Mixing
two copies' classes is worse than consistently running an older one, so the pin holds even if a
newer copy registers later. Don't touch package classes at plugin-file-load time; wait for
plugins_loaded. The registry reports early loads through _doing_it_wrong(). Code that has to run earlier can use the
preflight.
A theme can't ship a newer copy than its plugins. Like Action Scheduler, a theme's copy is ignored whenever a plugin bundles the same package.
Each package resolves on its own. The newest copy is picked per package, not per plugin. If
plugin A bundles the newest acme/widgets and plugin B bundles the newest acme/http, the
request runs A's widgets against B's http. Declare real version constraints between packages
that depend on each other, so every combination a bundle can produce is one Composer allowed.
Global functions come from the first copy. inc/public-api.php-style files entries are
declared once, by whichever copy loads first; keep them as forwarders (above).
Coordination only covers participating packages. A third-party plugin bundling the same package without this loader registers its own Composer autoloader, and resolution between the two is load-order dependent again.
The public surface is frozen. The first copy loaded declares Registry and owns
coordination for the request, so a newer copy's declaration is skipped entirely. Methods are
additive-only; anything incompatible must ship under a new class name.
Testing
composer test
tests/ComposerCopiesTest.php runs real composer installs of a fixture package bundled at two
versions, then loads them in each order in a fresh PHP process. It needs Composer: set
COMPOSER_BINARY, or have it in vendor/bin or on PATH.
License
GPL-2.0-or-later