Skip to content

Milpa

Milpa Runtime

The bootable Milpa kernel — composes milpa/core, milpa/container, milpa/events, milpa/http and milpa/resolver into a running app with a config-driven plugin registry, architecture resolution before boot, and lifecycle events. Zero database, zero magic.

CI Packagist PHP License Docs

milpa/runtime is where the rest of the family stops being separate packages and becomes an app. Kernel::boot() wires a DI container, an event dispatcher, a pre-boot architecture resolution over every configured plugin, an ordered boot loop that emits lifecycle events at each step, and a route table assembled from whatever plugins contribute one. The active-plugins list is whatever list<class-string> the caller passes in — a config array, a file required into that array, or filesystem discovery the caller performs beforehand. No Doctrine, no legacy Milpa\Web, no database-backed plugin registry — those, if you want them, live in your host application or a plugin you add on top.

Install

composer require milpa/runtime

Quick example

A plugin declares itself with #[PluginMetadata] and, optionally, contributes routes by implementing RouteProviderInterface:

use Milpa\Attributes\PluginMetadata;
use Milpa\Http\HttpMethod;
use Milpa\Http\Routing\HandlerReference;
use Milpa\Http\Routing\Route;
use Milpa\Http\Routing\RouteResult;
use Milpa\Interfaces\Di\DIContainerInterface;
use Milpa\Interfaces\Plugin\PluginInterface;
use Milpa\Runtime\Http\RouteProviderInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class HelloController
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $name = $request->getAttribute(RouteResult::ATTRIBUTE)?->parameter('name', 'world') ?? 'world';

        return new \Nyholm\Psr7\Response(200, ['Content-Type' => 'text/plain'], "hello, {$name}");
    }
}

#[PluginMetadata(version: '1.0.0', author: 'Acme', site: 'https://example.test', name: 'HelloPlugin', type: 'Web')]
final class HelloPlugin implements PluginInterface, RouteProviderInterface
{
    public function __construct(private readonly DIContainerInterface $container)
    {
    }

    public function boot(): void
    {
    }

    public function install(): void
    {
    }

    public function uninstall(): void
    {
    }

    public function enable(): void
    {
    }

    public function disable(): void
    {
    }

    /** @return list<Route> */
    public function routes(): array
    {
        return [
            new Route(
                path: '/hello/{name}',
                methods: HttpMethod::GET,
                name: 'hello',
                handler: new HandlerReference(HelloController::class, 'handle'),
            ),
        ];
    }
}

Kernel::boot() builds the container, resolves the architecture, boots the configured plugins in the order the resolution's own report dictates — its loadOrder[], still providesrequires, ties keeping the config order — and assembles the route table; RequestHandler matches a real PSR-7 request against it and dispatches to the resolved controller:

use Milpa\Runtime\Http\RequestHandler;
use Milpa\Runtime\Kernel;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;

$kernel = Kernel::boot(['plugins' => [HelloPlugin::class]]);
$kernel->bootedPluginNames(); // -> ['HelloPlugin']

$handler = new RequestHandler($kernel, new Psr17Factory());
$response = $handler->handle(new ServerRequest('GET', '/hello/milpa'));

$response->getStatusCode();    // -> 200
(string) $response->getBody(); // -> 'hello, milpa'

No plugin can leave the boot loop undetected: boot() resolves the whole architecture graph through milpa/resolver (each plugin's #[PluginMetadata] ingested by AttributeLoader, the graph resolved by GraphResolver) and throws ArchitectureBlockedException — a PluginDependencyException subclass, so every existing catch keeps working — before any plugin boots when the graph is blocked. The exception carries the full ResolutionReport on ->report, and its message is the report's own learnable first line: the error code, why it failed, the first fix, and an Academy learn link. The same resolution also orders the boot: the report's loadOrder[] (a dependency cycle blocks pre-boot as a learnable MILPA_DEPENDENCY_CYCLE, never a bare "circular dependency" crash). Capability entries ride in both shapes #[PluginMetadata] sanctions — a bare interface FQCN or a structured capability record — with every requires entry dispatched through CapabilityRequirement::parse(), so a rich record closes (or learnably blocks) the graph like any other dependency. Pass hostProfile (a HostProfile::fromArray() shape) in the config to resolve against your own architectural profile — absent, a deliberately permissive default keeps every graph that booted before booting still — and evaluatedAt (ISO-8601) as the clock for accepted-risk expiry. Every step along the way — architecture.resolved (carrying the resolver's full ResolutionReport, dispatched right before the unchanged capability.resolved), plugin.booting (vetoable via an InterceptionSlot), plugin.booted, kernel.booted — fires on the wired event dispatcher for observability or feature-flag plugins to hook into.

Every one of those names is declared to the dispatcher before the first dispatch. Kernel::boot() hands RuntimeEvents::declarations() — one EventDeclaration per event, built from the SAME constants the dispatch() sites use (RuntimeEvents::PLUGIN_BOOTING, …), naming the dispatching class, the moment it fires, the payload key and class of its subject, and whether an InterceptionSlot rides along — to any dispatcher implementing milpa/core's DeclaredEvents (milpa/events ≥ 0.4 does). A dispatcher that does not is told nothing and everything dispatches exactly the same: declaring is not enforced, it is counted, so the house can ask the dispatcher «what events exist, and which were dispatched undeclared» instead of grepping source (greenhouse decisions/0228). The falsifier is tests/TheKernelDeclaresEveryEventItDispatchesTest.php: a spy dispatcher under the real boot, the declared set held against the dispatched set, against the payload each dispatch really carried, and against the exact list of five.

Composes the family

milpa/runtime doesn't reimplement anything the family already ships — it wires the pieces together and adds the boot sequence on top:

Package Owns
milpa/core Contracts (PluginInterface, PluginMetadata, events) the whole family builds on.
milpa/container The DI container every plugin and controller is resolved through.
milpa/events The dispatcher every lifecycle event (plugin.booting/plugin.booted, architecture.resolved, capability.resolved, kernel.booted) fires on.
milpa/http Routing contracts — Route, RouteResult, RouterInterface — the route table is built from.
milpa/resolver The pre-boot architecture gate AND the boot order — AttributeLoader ingests each plugin's #[PluginMetadata], GraphResolver resolves the whole graph into the ResolutionReport that architecture.resolved carries, and that report's loadOrder[] is the sequence the boot loop follows.
milpa/runtime (this package) Kernel::boot() itself: the wiring, the pre-boot architecture resolution call, the ordered boot loop with lifecycle events, and Router/RequestHandler — a minimal RouterInterface implementation and PSR-15 entry point over the assembled route table.

Plugin boot strategies

The plugin phase inside Kernel::boot() is a swappable strategy (PluginBootStrategyInterface): it owns the whole phase — instantiation, the architecture gate, ordering, lifecycle events, the boot loop — and reports back through PluginBootResult (plugins, bootedPluginNames, and routes/commands, which default to empty). Everything around the phase — container, dispatcher, config, root before; router and kernel.booted after — stays the kernel's job. Inject a strategy via $config['pluginBoot']; without one, the kernel falls back to the default.

  • InlinePluginBootStrategy (default) — the pre-seam kernel phase, verbatim: instantiates $config['plugins'], gates the architecture through milpa/resolver, follows the report's loadOrder[], runs the boot loop. Byte-compatible with every kernel version before the seam existed — no config change means no behavior change.

  • PluginsManagerBootStrategy — delegates the whole phase to a host-grade plugins manager typed against milpa/core's Milpa\Interfaces\Plugin\PluginsManagerInterface (e.g. milpa/plugin's PluginsManager): registry-driven activation, resolver gate, two-layer caches, environment-gated tool registration and EventSubscriberInterface auto-subscription all happen inside the manager, so this strategy never re-implements them. It registers the manager under PluginsManagerInterface::class in the container (so plugins/commands can resolve it later), then calls addPluginPath() + loadPlugins() and reports whatever booted. Routes are intentionally left empty here: a host on this strategy assembles its route table outside the kernel (attribute scanning is a host concern), so the kernel's router boots empty by design. Commands are collected, exactly as the inline strategy collects them — a route is discovered by scanning, but an operation is declared by a plugin that already booted, and there is nothing left for a host to decide about it. Until 0.7.0 that sentence read "routes and commands", and the exclusion of commands rode in on the conjunction: milpa/plugin shipped seven plugin-management operations that no host on this strategy could ever see.

    milpa/plugin itself ships only as require-dev on this package today — enough to satisfy the test graph (PluginsManager, InMemoryPluginRegistry, ManagerConfig, PluginRecord) without adding a production dependency every milpa/runtime consumer pays for. Whether a given release promotes it to a production require (so hosts get PluginsManagerBootStrategy without also requiring milpa/plugin themselves) is a release-ceremony decision, not an architectural one — the strategy's own contract never changes either way.

Requirements

Documentation

Full API reference: getmilpa.github.io/runtime — 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.

About

The bootable Milpa kernel — composes the family into a running app.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages