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.pluginis the fully-qualified class name of the plugin's entry point. That class must implement theScriptor\Boot\Plugin\Plugininterface (onename(), oneversion(), oneregister(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:

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 insidevendor/(symlink, IDE refactor, hand-patching) without running Composer,installed.json's mtime won't move and the cache won't invalidate. Either runcomposer dump-autoloador callPluginManager::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(): deletedata/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.