Discovery hands the framework a list of plugin classes. The
PluginManager is what turns that list into actual running code: it
instantiates each plugin, hands it a registration surface, and keeps
a per-plugin record of what got registered so the editor can show
who-contributed-what.
This chapter walks the boot lifecycle one step at a time: what the manager owns, what guards each step, and what diagnostic hooks it exposes once everything is up.
What the manager owns
PluginManager is constructed once during framework boot, in
ImanagerBootstrap, with five inputs:
| Parameter | Source | Purpose |
|---|---|---|
container |
DI container | Passed to every plugin's PluginContext. |
vendorDir |
Path to vendor/ |
Where to read installed.json. |
cachePath |
data/cache/plugins.php |
Discovery cache location. |
disabled |
$config['plugins']['disabled'] |
FQCNs to skip. |
corePlugins |
Hardcoded list | First-party plugins shipped inside Scriptor. |
The manager is registered into the container as a shared instance, so anything downstream (editor modules, introspection surfaces) can ask the container for the same booted manager.
Boot order
bootAll() is the single entry point that wakes everything up.
Called once per request from the framework bootstrap, it walks two
lists in sequence:
bootAll()
│
├── for each $corePlugins[$i]: bootPluginClass($class, manifest: null)
│
└── for each discover()[$j]: bootPluginClass($class, manifest)
Core plugins go first by design. Both groups use the same
registration surface (PluginContext), and that surface has
last-write-wins semantics for editor modules and menu items. By
booting core first, any third-party plugin is free to take over a
slug a core plugin claimed: overriding the default Pages module,
swapping out the InstalledPlugins surface, replacing a menu entry.
bootAll() is also idempotent: a second call within the same
request short-circuits on the bootedPlugins !== [] guard. That
matters because both the frontend and editor entry points trigger
the bootstrap, and the bootstrap calls bootAll() unconditionally,
so the manager has to be safe against double-fire.
The guard chain inside bootPluginClass
For each candidate FQCN, the manager runs through four gates before
calling register():
private function bootPluginClass(string $class, ?PluginManifest $manifest): void
{
if (in_array($class, $this->disabled, true)) {
return; // [1] operator override
}
if (! class_exists($class)) {
return; // [2] missing autoload
}
$instance = new $class();
if (! $instance instanceof Plugin) {
return; // [3] wrong contract
}
$context = new PluginContext($this->container, $instance->name());
$instance->register($context); // [4] hand control over
$this->bootedPlugins[] = $instance;
$this->contexts[$instance->name()] = $context;
if ($manifest !== null) {
$this->manifestsByPluginName[$instance->name()] = $manifest;
}
}
Each gate fails silently: a missing class doesn't kill the request,
a wrong-contract class doesn't throw. The reasoning: plugin discovery
already validated extra.scriptor.plugin exists, so any failure
here means a deployment problem (autoloader out of sync, package
half-uninstalled, plugin renamed its entry class). Better to let
the rest of the site stay up than 500 the whole request.
Gotcha: silent skip. If
composer dump-autoloadis stale after a rename, the plugin disappears with no log line. The editor's Installed Plugins surface lists discovered manifests regardless of boot success, so cross-checking "discovered but not booted" is your debug signal.
One context per plugin
Every plugin gets its own PluginContext instance, not a shared
one. The context's job is twofold:
- Hand the plugin a typed surface to register against (events, editor modules, menu items, frontend nav builders).
- Record every registration as a side-effect on itself, so the manager can later introspect "what did plugin X contribute?"
Sharing one context across all plugins would still produce correct registrations (the underlying registries don't care which context called them), but the per-plugin breakdown the editor needs would be impossible. The cost of one extra object per plugin is trivial; the diagnostic value is high.
The full context API is the subject of the next chapter. For now, note that the manager never looks at what's inside a context. It just stores it and exposes it through one method.
Introspection: who registered what
Two methods on the manager exist purely to answer questions about booted state:
/** Returns a snapshot of what plugin X registered, or null if not booted. */
public function registrationsFor(string $pluginName): ?array
{
return isset($this->contexts[$pluginName])
? $this->contexts[$pluginName]->registrations()
: null;
}
/** Returns the Composer manifest (package name + version), or null for core. */
public function manifestFor(string $pluginName): ?PluginManifest
{
return $this->manifestsByPluginName[$pluginName] ?? null;
}
Both feed the editor's Installed Plugins module, the page at
/editor/plugins/ that lists every booted plugin with its event
subscriptions, editor modules, menu items, and nav builders. The
module also prefers manifestFor()->packageVersion over the
plugin's own version() method, because the source string tends to
drift across releases while the Composer manifest is authoritative.
Core plugins return null from manifestFor(). They don't have
an installed.json row because they ship inside Scriptor itself.
The InstalledPlugins module handles that case by labelling them as
"shipped with Scriptor" in the version column.
Tools in your hand
For day-to-day code you rarely call the manager directly. The two useful methods, both already mentioned in the discovery chapter, are worth repeating in their boot context:
discover(forceRefresh: true): re-scaninstalled.jsonignoring the cache. Useful right after a Composer operation in a long-lived process (CLI, test harness) where the in-memory$this->manifestsis also stale.bootedPlugins(): return the list of livePlugininstances in registration order (core first). Same instancesregister()was called on; the manager doesn't re-instantiate.
What you almost never need is bootAll() itself. The framework
calls it for you. If you find yourself reaching for it in
application code, the chances are you should be subscribing to an
event instead. That's the next chapter but two.
Behind the scenes. The manager source is
boot/Plugin/PluginManager.php.
The companion editor surface is
boot/Editor/Plugins/PluginsModule.php,
which consumes registrationsFor() and manifestFor() to render
the per-plugin breakdown.