Purpose
The discovery and boot layer for the plugin system. Scans
Composer's vendor/composer/installed.json for packages with
"type": "scriptor-plugin", instantiates the FQCN listed under
each package's extra.scriptor.plugin, and calls
Plugin::register($context) on every instance. Caches the
discovery result so the JSON scan does not run on every request,
and self-invalidates when Composer touches installed.json.
Plugins implement Plugin; plugins receive a PluginContext;
the manager is the thing in between. Theme code does not usually
touch the manager. The editor's InstalledPlugins module pulls it
out of the container to render the per-plugin breakdown.
FQCN + file path
- FQCN:
Scriptor\Boot\Plugin\PluginManager - Source:
boot/Plugin/PluginManager.php
When to use
Three angles:
- Reading the booted plugin list (
bootedPlugins()) from an admin page or diagnostic script. - Inspecting registrations for a specific plugin
(
registrationsFor(),manifestFor()). The editor's InstalledPlugins module is the only first-party consumer today. - Forcing a fresh discovery scan (
clearCache()) from a Composer post-install / post-update script, an admin tool, or a deploy script.
For everything else, the manager runs by itself; bootAll()
fires once per request from the boot sequence, and the plugins
take it from there.
Surface
public function discover(bool $forceRefresh = false): array
Returns the discovered PluginManifest list, using the cache
when available. Pass forceRefresh: true to bypass the cache and
re-scan installed.json once. Idempotent inside one request:
the result is also memoised on the instance.
public function bootAll(): void
Instantiates every plugin (core plugins first, then discovered
plugins; both sets skip anything in $disabled) and calls
register() on each. Idempotent: subsequent calls within the
same request are no-ops.
Each plugin gets its own PluginContext (not a shared one)
so the per-plugin registration breakdown stays accurate.
Contexts are retained on the manager for later introspection.
If a plugin's register() raises, the exception propagates;
there is no per-plugin try/catch at this layer. Wrap your own
plugin's register() if you need to fail soft.
public function bootedPlugins(): array
The list of Plugin instances that successfully booted. Order
matches boot order (core first, then discovered, in
installed.json order). Plugins that were disabled, failed
class_exists, or did not implement Plugin are absent.
public function registrationsFor(string $pluginName): ?array
Returns the registration snapshot for the named plugin (the
shape PluginContext::registrations() produces), or null
when the plugin is not booted. The lookup key is
Plugin::name(), not the package name or FQCN.
public function manifestFor(string $pluginName): ?PluginManifest
Returns the PluginManifest for a discovered plugin, or null
for core plugins (which ship with Scriptor and have no
installed.json record). The editor's PluginsModule prefers
PluginManifest::$packageVersion over Plugin::version() to
sidestep the source-string drift that used to need one-off
correction PRs.
public function clearCache(): void
Deletes the discovery cache file and clears the in-memory
manifests array. The next discover() call re-scans
installed.json. Call this from your Composer
post-install/post-update scripts so a fresh deploy picks up new
plugins on the first request instead of the second.
Constructor
public function __construct(
private readonly Container $container,
private readonly string $vendorDir,
private readonly string $cachePath,
private readonly array $disabled = [],
private readonly array $corePlugins = [],
)
$vendorDir: path to the Composervendor/directory. The manager reads$vendorDir/composer/installed.json.$cachePath: where to persist the discovery cache (defaultdata/cache/plugins.phpin Scriptor's wiring).$disabled: list of plugin FQCNs to skip at boot. Populated fromscriptor-config.php'splugins.disabledkey.$corePlugins: list of FQCNs Scriptor ships internally (not via Composer). They boot before discovered plugins so a user plugin can override any service or listener a core plugin registered.
You do not construct this yourself; boot/App.php wires it.
Lifecycle
Container-bound singleton, request-scoped. Constructed once
during boot, with the container, vendor path, cache path,
disabled list, and core-plugin list. The boot sequence then
calls bootAll() once.
Discovery rules
- Package's
composer.jsondeclares"type": "scriptor-plugin". - Same
composer.jsoncarries the plugin FQCN underextra.scriptor.plugin. - The FQCN must implement
Plugin. Classes that exist but do not implementPluginare silently dropped.
Cache shape and invalidation
The discovery cache is a generated PHP file
(data/cache/plugins.php by default) that returns a flat
array of {packageName, packageVersion, pluginClass} entries.
The manager invalidates it on read by comparing its mtime
against installed.json: any Composer install / update /
remove / dump-autoload rewrites installed.json, the cache
mtime falls behind, the cache is treated as missing, and the
next request re-scans.
clearCache() is the explicit lever; the mtime check is the
implicit one. Either is enough on its own; both together are
defensive.
Disabling without uninstalling
scriptor-config.php carries 'plugins' => ['disabled' => [...]]
with plugin FQCNs the manager should skip. Useful for keeping a
plugin installed (so composer install does not re-pull it) but
inactive (so it does not run on requests). Disabled plugins still
appear in discover() (they were found in installed.json) but
not in bootedPlugins().
Common patterns
Reading the booted list from a diagnostic page
$manager = $site->container->get(\Scriptor\Boot\Plugin\PluginManager::class);
foreach ($manager->bootedPlugins() as $plugin) {
printf("- %s %s\n", $plugin->name(), $plugin->version());
}
Forcing a re-scan after composer change
// scripts/postinstall.php (composer post-install)
require __DIR__ . '/../vendor/autoload.php';
\Scriptor\Boot\App::boot();
\Scriptor\Boot\App::container()
->get(\Scriptor\Boot\Plugin\PluginManager::class)
->clearCache();
Disabling a plugin in scriptor-config.php
return [
// ...
'plugins' => [
'disabled' => [
\Bigins\ScriptorMarkdownPages\Plugin::class,
],
],
];
Reading the registration breakdown
$manager = $site->container->get(\Scriptor\Boot\Plugin\PluginManager::class);
$breakdown = $manager->registrationsFor('bigins/scriptor-simple-router');
if ($breakdown !== null) {
printf(
"events: %d, modules: %d, menu items: %d, nav builders: %d\n",
count($breakdown['events']),
count($breakdown['modules']),
count($breakdown['menuItems']),
$breakdown['navBuilders'],
);
}
See also
Plugin: the interface every booted instance implementsPluginContext: the per-plugin context the manager constructs and hands toregister()PluginManifest: the cache-line shape the manager emits per discovered plugin- Concept: Plugin Manager: design walk-through (cache shape, mtime invalidation, why core plugins boot first)
- Concept: Plugin Discovery:
the
installed.jsonscan rules in depth