0015 — Plugin system: Composer-installed packages with extension points¶
- Status: accepted
- Date: 2026-04-26
- Deciders: @kackey621, Willen Federation contributors
Context and Problem Statement¶
Operators want to extend SASO without forking the project — add an ERP integration, swap the label-printing PDF engine, plug in an extra AI provider, or wire a domain-specific notification channel. Today the only way to add behaviour is to edit src/ and rebuild the release ZIP.
We need a plugin system that:
- lets third parties ship code as a Composer package and have SASO discover it on install;
- exposes a small, stable set of extension points — adding a route, hooking an item-write event, registering an
AuthProvider(ADR 0003) /AiAssistant(ADR 0009) /McpTool(ADR 0014); - respects the lockout-safety story —
SAFE_MODE=truedisables every plugin; - has a clear lifecycle (install → activate → upgrade → deactivate → uninstall) operators can audit;
- composes with the Strangler Fig migration (ADR 0001) — plugins live in their own namespace and never edit legacy code in place.
Decision Drivers¶
- Composer-native — operators already run
composer install. Plugins should ship ascomposer require willen-federation/saso-plugin-foo. - Schema-aware — plugins that need DB tables provide their own Phinx migrations (ADR 0007).
- Discoverable, not magical —
composer.jsondeclares the plugin's class via theextrablock; SASO reads it. No filesystem scans, no auto-registered hooks. - Zero plugin → zero overhead — when no plugins are installed, the boot path is unchanged.
- Compliance with ADR 0001 — extension-point interfaces live under
src/Domain/Plugin/and adapter glue undersrc/Infrastructure/Plugin/.
Considered Options¶
Option A — In-repo extension/ directory operators drop files into¶
- (+) Zero packaging.
- (−) Operators run
git pulland lose their plugin. No version pinning, no dependency resolution.
Option B — A bespoke plugin loader with a custom registry file¶
- (+) Decoupled from Composer.
- (−) Reinvents version pinning, autoload, security advisory scanning. Composer already does these.
Option C — Composer-discovered plugins via composer.json extra.saso.plugin¶
Plugins ship as Composer packages whose composer.json carries:
"extra": {
"saso": {
"plugin": {
"class": "Acme\\\\SasoLabelExtras\\\\Plugin",
"name": "Label extras",
"version-compat": "^1.0"
}
}
}
Saso\Domain\Plugin\Plugin is the lifecycle interface every plugin class implements. SASO walks vendor/composer/installed.json once at boot, instantiates each Plugin, and asks it to register() against a PluginContext that exposes the application's typed registries:
interface Plugin
{
public function metadata(): PluginMetadata;
public function register(PluginContext $ctx): void;
public function activate(PluginContext $ctx): void; // first install
public function deactivate(PluginContext $ctx): void; // disable / uninstall
}
interface PluginContext
{
public function authProviders(): AuthProviderRegistry;
public function aiAssistants(): AiAssistantRegistry;
public function mcpTools(): McpToolRegistry;
public function eventBus(): DomainEventBus;
public function routes(): ApiRouteRegistry;
public function settings(): SystemSettingService;
}
Plugins extend exactly the surfaces the registries expose. They cannot edit core code or the legacy directory.
- (+) Composer handles dependencies, autoload, version pinning, and security advisories.
- (+) Operators see plugins via
composer showand audit them viacomposer audit. - (+) Discovery is one-shot at boot — no runtime filesystem scan.
- (+) The contract surface is small (six registries) — easy to keep stable across major releases.
Decision Outcome¶
Chosen option: C — Composer-discovered plugins with typed registries.
Lifecycle¶
- Install —
composer require <plugin-package>. Composer pulls the package intovendor/. - Discover — first request after install:
Saso\Infrastructure\Plugin\PluginDiscoveryreadsvendor/composer/installed.json, finds entries withextra.saso.plugin, and instantiates each plugin class. - Activate — first time a plugin is seen, the loader calls
Plugin::activate($ctx)and writes a row toplugin_registry(package,class,version,activated_at). Idempotent — re-running yields no work. - Register — every boot calls
Plugin::register($ctx), which the plugin uses to add itself to the relevant registries. - Migrations — if the plugin's package ships
migrations/directory, the Phinx config (ADR 0007) picks them up via the dynamic-paths loader (M6-D2 work). - Deactivate —
composer remove <package>triggers a one-shotPlugin::deactivate($ctx)from the next admin-UI session, which writesdeactivated_attoplugin_registry. Plugin code stops loading. - Uninstall — Composer removes
vendor/<package>. Theplugin_registryrow remains so audit history survives.
Storage¶
CREATE TABLE plugin_registry (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
package VARCHAR(200) NOT NULL UNIQUE,
class VARCHAR(200) NOT NULL,
name VARCHAR(120) NOT NULL,
version VARCHAR(40) NOT NULL,
activated_at DATETIME NOT NULL,
deactivated_at DATETIME NULL,
last_seen_at DATETIME NULL,
settings_json JSON NULL, -- per-plugin config blob
KEY idx_active (deactivated_at)
);
Lockout safety¶
SAFE_MODE=trueskips every plugin'sregister()andactivate(). Core SASO boots without third-party code.- A plugin that throws from its constructor or
register()is logged aterrorlevel (Monolog), recorded inplugin_registry.last_seen_at = NULL, and skipped — one bad plugin must not brick the instance.
Permissions / scopes¶
Every registry has add / remove / replace semantics. Plugins cannot replace core providers (e.g. LocalProvider) — the registry rejects collisions on canonical names. They can add new providers, new tools, new routes.
Compatibility¶
extra.saso.plugin.version-compatis a Composer-style constraint against the SASO core version. The discovery step refuses to load a plugin whose constraint excludes the running version, and surfaces the mismatch in the admin UI.- Major SASO bumps may break the plugin contract. Each major release ships a migration guide.
Distribution¶
- A reference plugin scaffold (
willen-federation/saso-plugin-skeleton) ships in M6 documentation. Operators clone, rename, edit, andcomposer publish. - The first-party AI/Search/MCP capabilities stay in core; they are not plugins. The plugin surface is for third-party additions.
Consequences¶
- Operators install plugins with a single Composer command. Removing them is the inverse.
- The plugin contract is small enough that core code reviewers can keep the surface honest (six registries; refusal to expose internals).
plugin_registrygives operators a queryable answer to "what plugins are installed and active?".- We accept a one-time read of
vendor/composer/installed.jsonper request, mitigated by request-scoped caching of the resolved plugin list. - Plugins that need migrations get them auto-loaded via the M6-D2 Phinx-config dynamic paths. Until that lands (early M6), plugin authors must run their migrations by hand and document the manual step.