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; the vendor part 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's PluginManager greps vendor/composer/installed.json for. 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 require scriptor/scriptor. Plugins do not pull Scriptor as a dependency; Scriptor pulls plugins. Requiring it would create a circular install graph the moment a site composer requires the plugin into an existing Scriptor checkout.
  • "autoload": {"psr-4": {...}}: maps the namespace to src/. Every file in src/ follows PSR-4: src/Plugin.phpBigins\ScriptorSimpleRouter\Plugin, src/Router.phpBigins\ScriptorSimpleRouter\Router, and so on.
  • "extra.scriptor.plugin": the FQCN of the class implementing the Plugin interface. Scriptor's PluginManager reads 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 $context argument 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 an error_log is 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-demo stack): visible via docker 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 the package/var/log/ path instead.
  • Shared host (Apache mod_php, generic PHP-FPM): typically /var/log/php-fpm/error.log or whatever your php.ini's error_log directive points at. php -i | grep error_log from the CLI tells you for sure.

Wire the plugin into a local Scriptor

Two changes to your Scriptor checkout's composer.json:

  1. Add a path repository pointing at the plugin directory. Composer treats it like a regular package source, but reads straight from the path. The symlink option means edits in ~/code/scriptor-simple-router/src/ show up in Scriptor's vendor/ without a re-install.
  2. composer require the plugin at @dev stability, 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.php and compares the cache's mtime against installed.json. Any composer require/remove/update rewrites installed.json, which invalidates the cache automatically on the next request. You only need to delete data/cache/plugins.php by hand if you edit the plugin's composer.json (e.g. change the extra.scriptor.plugin FQCN) 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 type field is missing or misspelled in the plugin's composer.json (Discovery skips anything that is not exactly scriptor-plugin).
  • The extra.scriptor.plugin FQCN does not match the actual class (typo, wrong namespace, missing final class).
  • Composer's path repository points at the wrong directory. Verify with composer show bigins/scriptor-simple-router from the Scriptor root; it should list the install path back to your plugin source.

What just happened, in five sentences

  1. composer require resolved the path repository, installed your plugin into vendor/bigins/scriptor-simple-router, and rewrote vendor/composer/installed.json.
  2. On the next request, Scriptor's boot.php instantiated PluginManager and called discover().
  3. discover() compared installed.json's mtime against the cache, noticed the cache was stale, re-scanned installed.json for packages with type: scriptor-plugin, and read the extra.scriptor.plugin FQCN from each match.
  4. For each manifest, the manager autoloaded the FQCN (your Bigins\ScriptorSimpleRouter\Plugin), instantiated it, and called register($context).
  5. Your register() body fired the error_log line, 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 with composer.json and src/Plugin.php as above (or your own equivalent paths).
  • Scriptor's composer.json has the path repository entry and lists bigins/scriptor-simple-router under require (with :@dev or a dev-main constraint).
  • composer show bigins/scriptor-simple-router from 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.