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

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 Composer vendor/ directory. The manager reads $vendorDir/composer/installed.json.
  • $cachePath: where to persist the discovery cache (default data/cache/plugins.php in Scriptor's wiring).
  • $disabled: list of plugin FQCNs to skip at boot. Populated from scriptor-config.php's plugins.disabled key.
  • $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.json declares "type": "scriptor-plugin".
  • Same composer.json carries the plugin FQCN under extra.scriptor.plugin.
  • The FQCN must implement Plugin. Classes that exist but do not implement Plugin are 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