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()andversion(), including on pages that never use the plugin. Do no work in the constructor; defer it toregister(). register()runs only after the enabled + installed checks pass. It receives aPluginContextexposingevents()(the frontend + editor dispatcher),editorModules(),editorMenu(), andcontainer()(the DI container, for services likePageRepositoryor theSanitizer). 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:
-
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.1against tags, so an untagged repo offers onlydev-main. -
Expose a VCS repo. Since the package is not on Packagist, the consumer points Composer at the git URL with a one-time
repositoriesentry, then requires it:composer config repositories.scriptor-widget \ vcs https://github.com/acme/scriptor-widget composer require acme/scriptor-widget:^0.1In Docker, the same pair of facts goes in the
SCRIPTOR_PLUGIN_REPOSandSCRIPTOR_PLUGINSbuild args; see Installed plugins. -
Publish on Packagist (optional). A package on Packagist needs only the
composer require; therepositoriesstep disappears. For a public extension this is the friendliest path; for an internal one a VCS repo is enough. -
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.