milpa / runtime
The bootable Milpa kernel: composes milpa/core, milpa/container, milpa/events, milpa/http and milpa/resolver into container -> dispatcher -> architecture gate -> plugin boot in the resolver's loadOrder -> route registration, with zero Doctrine and zero legacy Web coupling. The active-plugins list is
Requires
- php: >=8.3
- composer-runtime-api: ^2.2
- milpa/command: >=0.3.1 <1.0
- milpa/container: >=0.1 <1.0
- milpa/core: >=0.12 <1.0
- milpa/events: >=0.2 <1.0
- milpa/http: >=0.1.4 <1.0
- milpa/resolver: >=0.5.2 <1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/log: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- milpa/plugin: >=0.7 <1.0
- nyholm/psr7: ^1.8
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- milpa/plugin: Required by PluginsManagerBootStrategy — the strategy that delegates the plugin phase to milpa/plugin. The default InlinePluginBootStrategy needs none of it.
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.16.0
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.2
- v0.8.1
- v0.8.0
- v0.7.9
- v0.7.8
- v0.7.7
- v0.7.6
- v0.7.5
- v0.7.4
- v0.7.3
- v0.7.2
- v0.7.1
- v0.7.0
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-fix/the-log-the-500-page-names-exists
- dev-feat/the-stack-reader-lives-next-to-the-contract
- dev-fix/emitter-defeats-output-buffering
- dev-feat/streaming-response-primitive
- dev-fix/reach-command-0-7
This package is auto-updated.
Last update: 2026-09-10 19:47:04 UTC
README
Milpa Runtime
The bootable Milpa kernel — composes
milpa/core,milpa/container,milpa/events,milpa/httpandmilpa/resolverinto a running app with a config-driven plugin registry, architecture resolution before boot, and lifecycle events. Zero database, zero magic.
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 provides → requires,
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 throughmilpa/resolver, follows the report'sloadOrder[], 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 againstmilpa/core'sMilpa\Interfaces\Plugin\PluginsManagerInterface(e.g. milpa/plugin'sPluginsManager): registry-driven activation, resolver gate, two-layer caches, environment-gated tool registration andEventSubscriberInterfaceauto-subscription all happen inside the manager, so this strategy never re-implements them. It registers the manager underPluginsManagerInterface::classin the container (so plugins/commands can resolve it later), then callsaddPluginPath()+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/pluginshipped seven plugin-management operations that no host on this strategy could ever see.milpa/pluginitself ships only asrequire-devon this package today — enough to satisfy the test graph (PluginsManager,InMemoryPluginRegistry,ManagerConfig,PluginRecord) without adding a production dependency everymilpa/runtimeconsumer pays for. Whether a given release promotes it to a productionrequire(so hosts getPluginsManagerBootStrategywithout also requiringmilpa/pluginthemselves) is a release-ceremony decision, not an architectural one — the strategy's own contract never changes either way.
Requirements
- PHP ≥ 8.3
milpa/core≥ 0.11milpa/command^0.3milpa/container^0.1milpa/events^0.2milpa/http^0.1.4milpa/resolver^0.5.2
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.