Every Scriptor plugin is at least two files: a composer.json that
flags the package as a plugin and points at the entry class, and a
Plugin class implementing Scriptor\Boot\Plugin\Plugin whose
register() method is the only place Scriptor hands control to
plugin code at boot. Anything richer (event subscriptions, DI
bindings, editor modules) is layered on top in later chapters.
This chapter writes those two files, wires the in-progress plugin into your local Scriptor via a Composer path repository, and reloads the front page. By the end you have a working plugin. It does nothing yet, but it loads, and you can see a log line proving it.
Pick the plugin directory
A plugin lives outside the Scriptor install. Composer eventually
installs it into Scriptor's vendor/, but during development you
want the source in its own directory so you can git init it,
iterate with your editor of choice, and never confuse plugin code
with Scriptor code.
We'll use ~/code/scriptor-simple-router/ throughout this track.
Substitute your own path; just keep it outside whatever directory
holds your Scriptor checkout.
mkdir -p ~/code/scriptor-simple-router/src
cd ~/code/scriptor-simple-router
Two directories: the plugin root (scriptor-simple-router/) for
composer.json + README.md, and src/ for the PHP classes that
PSR-4 will autoload.
Write composer.json
{
"name": "bigins/scriptor-simple-router",
"description": "Tiny URL router that runs ahead of Scriptor's page resolver.",
"type": "scriptor-plugin",
"license": "MIT",
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Bigins\\ScriptorSimpleRouter\\": "src/"
}
},
"extra": {
"scriptor": {
"plugin": "Bigins\\ScriptorSimpleRouter\\Plugin"
}
}
}
Five fields carry weight. Walk through them:
"name": the Composer package name. The<vendor>/<package>shape is Composer's; thevendorpart is whatever GitHub account or organisation publishes the plugin. Use your own here if you are following along on your own account."type": "scriptor-plugin": the marker Scriptor'sPluginManagergrepsvendor/composer/installed.jsonfor. Without it, the plugin is invisible at boot, even if everything else is in place. See Concepts → Plugin Discovery for the discovery algorithm."require": {"php": "^8.2"}: notice it does not requirescriptor/scriptor. Plugins do not pull Scriptor as a dependency; Scriptor pulls plugins. Requiring it would create a circular install graph the moment a sitecomposer requires the plugin into an existing Scriptor checkout."autoload": {"psr-4": {...}}: maps the namespace tosrc/. Every file insrc/follows PSR-4:src/Plugin.php→Bigins\ScriptorSimpleRouter\Plugin,src/Router.php→Bigins\ScriptorSimpleRouter\Router, and so on."extra.scriptor.plugin": the FQCN of the class implementing thePlugininterface. Scriptor'sPluginManagerreads this key to know what to instantiate. The class must exist and implement the interface; otherwise the manager logs and skips.
Save the file. No composer install yet inside the plugin directory
itself; nothing to install (the plugin's own require is just PHP).
The first time composer install matters is when you wire the
plugin into a Scriptor checkout, below.
Write the Plugin class
Create src/Plugin.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;
final class Plugin implements ScriptorPlugin
{
public function name(): string
{
return 'bigins/scriptor-simple-router';
}
public function version(): string
{
return '0.0.1';
}
public function register(PluginContext $context): void
{
// Phase 2 leaves register() empty on purpose. Chapter 3
// adds the Router singleton; chapter 5 hooks _ext.php.
error_log('[scriptor-simple-router] plugin registered (no-op)');
}
}
Three methods, all required by the interface:
name()returns a human-readable identifier. Convention is the Composer package name; nothing enforces it, but logs and the future editor surface will list it verbatim.version()returns a semver string. Informational only. The loader does not parse or constrain it; it shows up in diagnostic output.register()is the only entrypoint. Scriptor calls it once per request, after the iManager container is booted and before any routing or rendering. The$contextargument is the surface plugins use to influence the application (subscribe to events, contribute to nav, register editor modules). Chapter 3 starts using it; for now anerror_logis enough to prove the method ran.
error_log() writes to whatever PHP's error_log ini setting
points at. Three common destinations:
- Docker (the bundled
bigins/scriptor-demostack): visible viadocker logs scriptor-demo. - ServBay (macOS):
/Applications/ServBay/package/var/log/php/<version>/errors.log. Don't be fooled by the empty/Applications/ServBay/logs/php-fpm/tree; ServBay writes to thepackage/var/log/path instead. - Shared host (Apache mod_php, generic PHP-FPM): typically
/var/log/php-fpm/error.logor whatever yourphp.ini'serror_logdirective points at.php -i | grep error_logfrom the CLI tells you for sure.
Wire the plugin into a local Scriptor
Two changes to your Scriptor checkout's composer.json:
- Add a path repository pointing at the plugin directory.
Composer treats it like a regular package source, but reads
straight from the path. The
symlinkoption means edits in~/code/scriptor-simple-router/src/show up in Scriptor'svendor/without a re-install. composer requirethe plugin at@devstability, since path repositories ship the working copy as a dev version.
From your Scriptor root:
# Edit composer.json, add the repositories block:
{
"repositories": [
{
"type": "path",
"url": "/Users/<you>/code/scriptor-simple-router",
"options": {
"symlink": true
}
}
]
}
Then:
composer require bigins/scriptor-simple-router:@dev
(If a repositories block already exists for bigins/* plugins
from earlier work, just add this one alongside it.)
composer require does three things in one go: writes the new
package into composer.json, installs it into
vendor/bigins/scriptor-simple-router (a symlink to your dev
directory), and rewrites vendor/composer/installed.json. That
last file is what Scriptor's PluginManager scans at boot.
Cache invalidation. Scriptor caches its plugin scan result in
data/cache/plugins.phpand compares the cache's mtime againstinstalled.json. Anycomposer require/remove/updaterewritesinstalled.json, which invalidates the cache automatically on the next request. You only need to deletedata/cache/plugins.phpby hand if you edit the plugin'scomposer.json(e.g. change theextra.scriptor.pluginFQCN) without running a Composer command.
Verify Discovery
Reload http://localhost:<port>/ once and tail the PHP error log.
You should see exactly one line per request:
[scriptor-simple-router] plugin registered (no-op)
Pick the tail command that matches your stack:
# Docker
docker logs scriptor-demo 2>&1 | tail -10
# ServBay (adjust the PHP version to match what your site uses)
tail -10 /Applications/ServBay/package/var/log/php/8.3/errors.log
# Shared host / generic PHP-FPM
tail -10 /var/log/php-fpm/error.log
If you don't see the line, the most likely causes are:
- The
typefield is missing or misspelled in the plugin'scomposer.json(Discovery skips anything that is not exactlyscriptor-plugin). - The
extra.scriptor.pluginFQCN does not match the actual class (typo, wrong namespace, missingfinal class). - Composer's path repository points at the wrong directory. Verify
with
composer show bigins/scriptor-simple-routerfrom the Scriptor root; it should list the install path back to your plugin source.
What just happened, in five sentences
composer requireresolved the path repository, installed your plugin intovendor/bigins/scriptor-simple-router, and rewrotevendor/composer/installed.json.- On the next request, Scriptor's
boot.phpinstantiatedPluginManagerand calleddiscover(). discover()comparedinstalled.json's mtime against the cache, noticed the cache was stale, re-scannedinstalled.jsonfor packages withtype: scriptor-plugin, and read theextra.scriptor.pluginFQCN from each match.- For each manifest, the manager autoloaded the FQCN (your
Bigins\ScriptorSimpleRouter\Plugin), instantiated it, and calledregister($context). - Your
register()body fired theerror_logline, then returned.
The whole flow is in
boot/Plugin/PluginManager.php,
under 300 lines, worth a one-time read.
To-do before chapter 3
-
~/code/scriptor-simple-router/exists withcomposer.jsonandsrc/Plugin.phpas above (or your own equivalent paths). - Scriptor's
composer.jsonhas the path repository entry and listsbigins/scriptor-simple-routerunderrequire(with:@devor adev-mainconstraint). -
composer show bigins/scriptor-simple-routerfrom the Scriptor root prints the package and confirms the install location. - Reloading
/produces the[scriptor-simple-router] plugin registered (no-op)line in the PHP error log.
When the log line is reliably there, open chapter 3 and turn the
empty register() into the start of a real router.
Behind the scenes. The Plugin interface lives at
boot/Plugin/Plugin.php.
The Discovery algorithm (cache, mtime check, installed.json scan)
is the first ~120 lines of
boot/Plugin/PluginManager.php.
The PluginContext surface that chapter 3 will start using lives at
boot/Plugin/PluginContext.php.