Before Scriptor can boot a plugin it has to find it. Discovery is the first link in the chain: a small piece of machinery that turns "the user ran composer require bigins/scriptor-markdown-pages" into "there is a plugin class to instantiate next request."

This chapter walks through the rules a package has to satisfy, the scan that runs once per cache-bust, and the two escape hatches for operators: disabling a plugin, and forcing a fresh scan.

What makes a package a plugin

A Scriptor plugin is a normal Composer package with two extra declarations in its composer.json:

{
    "name": "bigins/scriptor-markdown-pages",
    "type": "scriptor-plugin",
    "extra": {
        "scriptor": {
            "plugin": "Bigins\\ScriptorMarkdownPages\\Plugin"
        }
    }
}

Two things matter here:

  • "type": "scriptor-plugin" is the discovery marker. The scan ignores every package whose type is something else (libraries, themes, projects, ...).
  • extra.scriptor.plugin is the fully-qualified class name of the plugin's entry point. That class must implement the Scriptor\Boot\Plugin\Plugin interface (one name(), one version(), one register(PluginContext) method; see the next chapter).

That's the entire contract. There is no plugin registry to push to, no central list to edit, no activation step. Once composer require finishes, the next request picks the plugin up.

How the scan works

When the framework boots, PluginManager::discover() reads vendor/composer/installed.json, the file Composer writes every time it installs, updates, or removes a package. The scanner walks the package list, filters by type, and reads extra.scriptor.plugin out of each survivor:

Plugin boot

Both Composer 2's wrapped layout ({"packages": [...]}) and the legacy Composer 1 flat list are accepted, so the scan keeps working through Composer upgrades and against any tooling that regenerates installed.json oddly.

The discovery cache

Walking JSON on every request would be wasteful, so the scan result lands in a PHP file at data/cache/plugins.php. The cache is a plain return [...]; array of three-field rows (package name, version, plugin FQCN), included verbatim on the next request.

What makes the cache safe to keep around is mtime-based self-invalidation. On every boot the manager compares the cache file's mtime against vendor/composer/installed.json:

filemtime(installed.json) > filemtime(cache)  →  re-scan
filemtime(installed.json) ≤ filemtime(cache)  →  use cache

Composer rewrites installed.json on install, update, remove, and dump-autoload. Any of those bumps the mtime, so the cache becomes stale the moment dependencies change. No Composer plugin or post-install hook needed.

Gotcha: manual vendor/ edits. If you edit a plugin in place inside vendor/ (symlink, IDE refactor, hand-patching) without running Composer, installed.json's mtime won't move and the cache won't invalidate. Either run composer dump-autoload or call PluginManager::clearCache() to force a fresh scan.

Disabling a plugin without uninstalling it

Sometimes you want a plugin off but not gone: A/B tests, hotfixes, debugging which extension broke production. The plugin manager accepts a list of FQCNs to skip at boot, read from scriptor-config.php:

// data/settings/scriptor-config.php
return [
    // ...
    'plugins' => [
        'disabled' => [
            'Bigins\\ScriptorMarkdownPages\\Plugin',
        ],
    ],
];

Disabled plugins are still discovered (they appear in the manifest list, and the future "Installed Plugins" editor surface still lists them), but the manager skips bootPluginClass() so their register() never runs.

Core plugins boot first

Scriptor itself ships a couple of first-party plugins inside its own codebase. They don't go through installed.json because they're not Composer packages. These core plugins are passed to the manager as a constructor argument and they boot before any discovered plugin:

boot order:
  1. core plugins, in declaration order
  2. discovered plugins, in installed.json order

This order matters for override semantics. The PluginContext methods that touch a registry (editor modules, menu items) treat re-registration as replacement: same slug wins the last write. By booting core first, user plugins are free to override any service or listener the core plugins set up.

Tools in your hand

For day-to-day use you never call the discovery API directly, but two methods are worth knowing about:

  • PluginManager::discover(forceRefresh: true): bypass the cache and re-scan immediately. Useful in tests and in debug shells.
  • PluginManager::clearCache(): delete data/cache/plugins.php. Composer post-install / post-update scripts can call this to be explicit about cache-busting, even though the mtime check already handles it.

Once a plugin shows up in the discovered list, it's the PluginManager's job to actually instantiate it and call register(). That's the next chapter.


Behind the scenes. The discovery logic lives in boot/Plugin/PluginManager.php, methods discover() / scanInstalledJson() / readCache() / writeCache(). The PluginManifest value object is the row shape the cache persists.