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-autoload is 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:

  1. Hand the plugin a typed surface to register against (events, editor modules, menu items, frontend nav builders).
  2. 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-scan installed.json ignoring the cache. Useful right after a Composer operation in a long-lived process (CLI, test harness) where the in-memory $this->manifests is also stale.
  • bootedPlugins(): return the list of live Plugin instances in registration order (core first). Same instances register() 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.