milpa/plugin

The plugin system of the Milpa PHP framework: the PluginsManager runtime that boots and orders plugins, the registry port with its file and in-memory adapters, PluginBase, the installer over GitHub-native distribution (source parsing, semver-constrained release resolution, zipball download), milpa.j

Maintainers

Package info

github.com/getmilpa/plugin

pkg:composer/milpa/plugin

Transparency log

Statistics

Installs: 279

Dependents: 4

Suggesters: 1

Stars: 0

Open Issues: 0

v0.3.0 2026-07-27 23:21 UTC

This package is auto-updated.

Last update: 2026-07-27 23:21:27 UTC


README

Milpa

Milpa Plugin

The plugin system of the Milpa PHP framework — the runtime that boots and orders plugins, the registry port that decides which ones are on, and GitHub-native distribution with no registry server required.

CI Packagist PHP License Docs

milpa/plugin is everything a host needs to have plugins at all, in two halves that a host can adopt separately.

The runtime. PluginsManager scans, orders, and boots plugins, resolving their capability graph and caching the result; PluginBase is the class a plugin extends; and PluginRegistryInterface is the port that answers which plugins are on — with a JSON-file adapter and an in-memory one included, and a contract test base so a host can write its own (a database-backed one, say) and prove it behaves.

Distribution. PluginInstaller runs install / update / remove over a PluginDownloaderInterface — implemented here by GitHubDownloader: resolve a semver constraint against GitHub releases/tags, download and extract the matching zipball, read and validate the plugin's milpa.json manifest, resolve its plugin + Composer dependencies, run its migrations, and record the result in a milpa.lock file. No registry server, no Packagist-style index — GitHub itself is the source of truth, and any other source of plugins is one implementation of the downloader port away.

Install

composer require milpa/plugin

Quick example

use Milpa\Plugin\ContractResolver;
use Milpa\Plugin\DependencyResolver;
use Milpa\Plugin\GitHubDownloader;
use Milpa\Plugin\LockFileManager;
use Milpa\Plugin\PluginManifest;

// 1. Parse a GitHub source string into owner/repo/constraint.
$downloader = new GitHubDownloader();
$downloader->parseSource('acme/mail-plugin:^2.0');
// -> ['owner' => 'acme', 'repo' => 'mail-plugin', 'constraint' => '^2.0']

// 2. Read + validate a milpa.json manifest (fromArray() mirrors fromPath()).
$manifest = PluginManifest::fromArray([
    'name' => 'acme/mail-plugin',
    'version' => '2.1.0',
    'entrypoint' => 'MailPlugin.php',
    'namespace' => 'Acme\\MailPlugin',
    'contracts' => ['requires' => ['database']],
]);
$manifest->validate(); // throws InvalidArgumentException on a malformed manifest

// 3. Order plugins by their declared contracts (Kahn's algorithm; throws on cycles).
$resolver = new ContractResolver();
$loadOrder = $resolver->getLoadOrder([
    ['name' => 'DatabasePlugin', 'class' => 'Acme\\DatabasePlugin', 'provides' => ['database']],
    ['name' => 'acme/mail-plugin', 'class' => 'Acme\\MailPlugin', 'requires' => ['database']],
]);
// -> load order: DatabasePlugin, acme/mail-plugin (providers before consumers)

// 4. Resolve dependencies before installing (plugin deps, contracts, composer.lock).
$deps = new DependencyResolver(getcwd());
$resolution = $deps->resolve($manifest, [
    ['name' => 'DatabasePlugin', 'provides' => ['database']],
]);
// -> $resolution->resolvable === true, $resolution->conflicts === []

// 5. Record installed state in milpa.lock.
$lock = new LockFileManager(getcwd());
$lock->generate([
    ['name' => 'acme/mail-plugin', 'version' => '2.1.0', 'source' => 'github:acme/mail-plugin'],
]);
$lock->verify(); // true — the SHA-256 content hash matches

Managing plugins from any surface

Listing, enabling, installing and removing a plugin are operations (milpa/command), not CLI commands — defined once, projected by the host to whichever surfaces it runs. Add PluginManagementPlugin to the host's plugin list and the same seven operations appear in the terminal, over HTTP, and to an MCP client, without a controller per surface:

Operation Mutating Confirms Scope
plugins.list / plugins.show no no plugins:read
plugins.enable / plugins.disable yes no plugins:write
plugins.install / plugins.update yes yes plugins:install
plugins.remove yes yes plugins:write
use Milpa\Plugin\Operations\PluginManagementPlugin;

return [
    PluginManagementPlugin::class,
    // ...
];

What a host gets depends on what it wired. With a PluginRegistryInterface in the container it gets the four read-and-toggle operations; wire a PluginInstallerInterface too and the three that reach out for code appear as well — a host without an installer never renders an install button that fails when pressed.

Declared in code, switched at runtime

A host has plugins from two places, and ActivePlugins decides which of them boot:

// config/boot.php — the one call, so the store the kernel boots from and the
// store the operations write to cannot end up being two different files.
$container = new DIContainer();
$plugins = ActivePlugins::wire($container, require __DIR__ . '/plugins.php', __DIR__ . '/../storage/plugins.json');
  • A declared class with no record boots. Adding a line to the list is all it takes, and an app that never manages anything never grows a state file.
  • A declared class the store says is disabled does not boot. The first time anyone switches one off, that is when its record is created.
  • A record that is installed, enabled and whose class resolves boots, even though nobody declared it — a plugin installed at runtime.
  • A declared plugin cannot be removed from a surface: it is named by the app's own code, so the operation says to delete its line instead. Disabling it is what a surface can do.

That separation is the whole point. Activation is state, so a panel can change it; the list is code, so a person reads it in a diff. Neither surface ever writes PHP back to disk.

Two decisions are deliberate. The three operations that fetch and run somebody else's code declare requiresConfirmation, so whatever surface is driving gets to put that in front of a person. And a freshly installed plugin arrives disabled: installing is not consenting to run it.

Generating a canonical manifest

PluginManifest::generateFromMetadata() turns a plugin's #[PluginMetadata] into milpa.json data — and the capability entries decide the emitted shape; the generator never invents metadata:

  • every entry a structured record ({id, interface, contractVersion, service, …}) → the canonical capabilities block, each record validated through core's capability value objects plus a generation-time provider check (service present, autoloadable, and implementing the declared interface). A record that fails validation is a hard failure (InvalidArgumentException), never a silent downgrade;
  • every entry a bare FQCN string → the legacy contracts block exactly as before, plus a $warnings entry teaching how to reach canonical;
  • a mix of both shapes in one plugin → hard failure: one plugin migrates atomically.

The canonical shape is frozen in schema/milpa-plugin.schema.json, which ships with this package — the suite's schema-conformance tests run against that exact file. Overwrite policy is the host's concern: a host command (e.g. coa:plugins manifest --force) decides whether an existing milpa.json may be replaced; the generator only returns the array.

What lives where

Class Responsibility
GitHubDownloader Parses owner/repo[:constraint] / full GitHub URLs, lists releases/tags via the GitHub REST API, resolves the best version for a constraint, and downloads + extracts the matching zipball. Reads GITHUB_TOKEN for private repos or higher rate limits.
PluginManifest Reads and validates a milpa.json manifest (fromPath() / fromArray()), exposes typed accessors (getProvides(), getRequires(), getSuggests(), typed CapabilityProvision/CapabilityRequirement/CapabilitySuggestion records, Composer/plugin dependencies, PHP version constraint, env vars), and generates a manifest from #[PluginMetadata] via generateFromMetadata() — canonical capabilities block for rich records, legacy contracts block (with a teaching warning) for bare FQCNs, hard failure on a mix.
ContractResolver Validates that every plugin's requires is satisfied by some other plugin's provides (fail-fast, throws RuntimeException; suggests only logs), and topologically sorts plugins into a load order where providers come before consumers.
DependencyResolver Resolves a plugin's contract requirements, plugin-to-plugin dependencies (with version constraint checks), and Composer dependencies (read from composer.lock) into a single Milpa\DTO\DependencyResolutionresolvable, conflicts, missingPlugins, composerPackages, satisfiedContracts.
LockFileManager Generates, reads, and verifies milpa.lock — installed plugin names, versions, sources, install timestamps, and a SHA-256 content hash for integrity checks.
Runtime\PluginsManager The boot runtime: scans a plugins path for #[PluginMetadata], resolves the capability graph through milpa/resolver, caches the resulting load order and revalidates it against the sources, boots each plugin in order, and auto-registers the tools and event subscriptions a plugin declares.
PluginBase The base class a plugin extends: container access, service registration, and entity discovery against the PluginSchemaManagerInterface port.
PluginInstaller The install / update / remove pipeline. Every collaborator is a port — the source of the code, the activation store, the migration runner, and composer — so an invalid or failing composer package aborts the whole operation before any file or registry state changes.
PluginMigrationRunner Runs a plugin's versioned migrations up and down against a MigrationLedgerInterface.
PluginScaffolder Generates the skeleton of a new plugin.
Contracts\* The ports: PluginRegistryInterface (which plugins are installed and enabled), PluginDownloaderInterface (where their code comes from), ComposerRunnerInterface, MigrationLedgerInterface, PluginSchemaManagerInterface.
Registry\FilePluginRegistry / Registry\InMemoryPluginRegistry The two adapters that ship: a JSON file, and memory. Testing\PluginRegistryContractTestBase is the suite any third adapter must pass.

Requirements

  • PHP ≥ 8.3
  • milpa/core ^0.6
  • milpa/command ^0.2 — the Operation atom the plugins.* operations are defined as
  • milpa/events ^0.2PluginsManager dispatches the kernel's boot events
  • milpa/resolver ^0.5.2 — the capability graph. The floor is .2 and not ^0.5: PluginsManager hands every #[PluginMetadata] to the resolver's AttributeLoader, whose rich-record ingestion first ships in 0.5.2; against ≤ 0.5.1 a rich record TypeErrors inside the resolver
  • psr/log ^3
  • psr/http-client ^1.0 and psr/http-factory ^1.0 — the optional transport seam for GitHubDownloader

Documentation

Full API reference: getmilpa.github.io/plugin — generated straight from the source DocBlocks and dressed with the Milpa design system.

Contributing

Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.

License

Apache-2.0 © Rodrigo Vicente - TeamX Agency.

Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.