What this covers

You wrote a plugin or a theme and want other Scriptor sites to be able to install it. This page is the contract and the release flow: what composer.json must declare, the entry-class interface a plugin implements, and the steps that turn a directory of code into something composer require can pull in.

It does not re-teach how plugins or themes work internally; that is the Developer Guide concepts, the Build a Theme and Build a Module tutorials, and the API Reference. This is the shipping checklist on top of them.

Plugins

The composer.json shape

A plugin is a Composer package of type: scriptor-plugin that names its entry class under extra.scriptor.plugin:

{
    "name": "acme/scriptor-widget",
    "description": "One-line summary shown in catalogs.",
    "type": "scriptor-plugin",
    "license": "MIT",
    "keywords": ["scriptor", "scriptor-plugin"],
    "require": {
        "php": "^8.2"
    },
    "autoload": {
        "psr-4": { "Acme\\ScriptorWidget\\": "src/" }
    },
    "extra": {
        "scriptor": {
            "plugin": "Acme\\ScriptorWidget\\Plugin"
        }
    }
}

The two non-negotiable keys are type (Scriptor's discovery scans vendor/composer/installed.json for scriptor-plugin packages at boot) and extra.scriptor.plugin (the fully-qualified entry class). Without either, the package installs but Scriptor never sees it.

The entry-class contract

The class named in extra.scriptor.plugin implements the Scriptor\Boot\Plugin\Plugin interface, three methods:

<?php

declare(strict_types=1);

namespace Acme\ScriptorWidget;

use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    public function name(): string
    {
        return 'acme/scriptor-widget';   // the Composer package name
    }

    public function version(): string
    {
        return '0.1.0';                   // shown in the editor; not parsed
    }

    public function register(PluginContext $context): void
    {
        // Wire the plugin in here.
    }
}

Two things the interface's own docblock calls out, worth repeating:

  • Keep the constructor cheap. Discovery instantiates every installed plugin on every request just to read its name() and version(), including on pages that never use the plugin. Do no work in the constructor; defer it to register().
  • register() runs only after the enabled + installed checks pass. It receives a PluginContext exposing events() (the frontend + editor dispatcher), editorModules(), editorMenu(), and container() (the DI container, for services like PageRepository or the Sanitizer). That one object is the whole surface, so the method signature stays stable as Scriptor grows.

A plugin that hooks nothing at registration time (a feed or router activated from a theme guard line, say) still ships this class with a no-op register(), so discovery can list it.

Plugins that own state: LifecyclePlugin

If your plugin creates database tables, a settings file, or an uploads subdirectory, implement Scriptor\Boot\Plugin\LifecyclePlugin (which extends Plugin) and add install() / uninstall():

use Scriptor\Boot\Plugin\LifecyclePlugin;

final class Plugin implements LifecyclePlugin
{
    // name(), version(), register() as above

    public function install(PluginContext $context): void
    {
        // create tables / register fields / seed defaults
    }

    public function uninstall(PluginContext $context): void
    {
        // remove what install() made
    }
}

bin/scriptor plugin:install <package> and plugin:uninstall <package> call these; the framework never auto-invokes them. Both take the same PluginContext as register(), so install() reaches repositories through $context->container(). Make them idempotent: install runs cleanly when the state already exists, uninstall when it does not. By convention uninstall() removes schema (field definitions, custom categories) but leaves row values in items.data so a later reinstall finds them; the operator passes --purge-data to wipe values too, which arrives as $context->purgeDataRequested. A stateless plugin (it only subscribes to events) skips this interface entirely; there is no install step to run. LifecyclePlugins are tracked in data/plugin-states.json.

Themes

A theme is a Composer package of type: scriptor-theme that lives under themes/<name>/. It owns the frontend HTML, the asset pipeline, and the template chunks; it is not a plugin and implements no entry class. See basic-theme for the reference layout and the Build a Theme tutorial for building one from an empty directory.

The minimum a theme ships is a composer.json declaring the type and an autoload entry for its lib/ classes, plus the template files Scriptor's theme loader expects (template.php, default.php, and an _ext.php bootstrap). The tutorial covers each surface in order.

Release flow

Plugins and themes follow the same path, because neither is published on Packagist by convention:

  1. Tag a release. Commit a version() bump (plugins) and tag the matching SemVer version: git tag -a v0.1.0 -m "v0.1.0" && git push --follow-tags. Composer resolves ^0.1 against tags, so an untagged repo offers only dev-main.

  2. Expose a VCS repo. Since the package is not on Packagist, the consumer points Composer at the git URL with a one-time repositories entry, then requires it:

    composer config repositories.scriptor-widget \
      vcs https://github.com/acme/scriptor-widget
    composer require acme/scriptor-widget:^0.1
    

    In Docker, the same pair of facts goes in the SCRIPTOR_PLUGIN_REPOS and SCRIPTOR_PLUGINS build args; see Installed plugins.

  3. Publish on Packagist (optional). A package on Packagist needs only the composer require; the repositories step disappears. For a public extension this is the friendliest path; for an internal one a VCS repo is enough.

  4. Open a catalog PR. To get your extension listed on this site, add a page under content/extensions/ following the five-section shape every catalog entry uses (What it does / Install / Configure / Use / Links) and open a PR. The Extensions index describes the shape and what does and does not belong in the catalog.

A note on documenting the install step

If you write your own README, document the install path against a clean Scriptor: the composer config repositories … vcs … step followed by composer require. Scriptor ships with no plugin repositories declared in its composer.json, so the consumer always supplies the repo URL. (Earlier plugin READMEs claimed Scriptor pre-declared a repositories block; that is no longer true.)

Where to go next

  • Build a Module builds a publishable plugin (scriptor-simple-router) from scratch.
  • Build a Theme does the same for a theme (the Atelier mini-case).
  • The Cookbook has the smaller plugin-authoring recipes (custom events, CommonMark extensions, CLI scripts) you will reach for once the skeleton is in place.
  • Building a whole site on Scriptor (not a single extension) is a different shape: fork Scriptor and overlay your theme, content and Docker setup, pulling engine updates with git merge. See Scriptor's Deploy a site as a fork guide.