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
Requires
- php: >=8.3
- milpa/command: ^0.2
- milpa/core: ^0.6
- milpa/events: ^0.2
- milpa/resolver: ^0.5.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/log: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- nyholm/psr7: ^1.8
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- phpunit/phpunit: Required to extend the shipped registry contract test base (Milpa\Plugin\Testing) — production code never loads it
README
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.
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 canonicalcapabilitiesblock, each record validated through core's capability value objects plus a generation-time provider check (servicepresent, 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
contractsblock exactly as before, plus a$warningsentry 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\DependencyResolution — resolvable, 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.6milpa/command^0.2 — theOperationatom theplugins.*operations are defined asmilpa/events^0.2 —PluginsManagerdispatches the kernel's boot eventsmilpa/resolver^0.5.2 — the capability graph. The floor is.2and not^0.5:PluginsManagerhands every#[PluginMetadata]to the resolver'sAttributeLoader, whose rich-record ingestion first ships in 0.5.2; against ≤ 0.5.1 a rich record TypeErrors inside the resolverpsr/log^3psr/http-client^1.0 andpsr/http-factory^1.0 — the optional transport seam forGitHubDownloader
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.