milpa / skeleton
The composer create-project starting point for a Milpa app: boots milpa/runtime's Kernel, serves a real HTTP response with zero database, and ships a coa CLI (doctor/validate/make/inspect, plus every plugin-declared CommandProviderInterface command) wired to milpa/devtools. Minimal by default — the
Requires
- php: >=8.3
- milpa/auth: ^0.1
- milpa/command: ^0.1
- milpa/container: ^0.1
- milpa/core: ^0.6
- milpa/data: ^0.2
- milpa/devtools: ^0.6
- milpa/events: ^0.2
- milpa/http: ^0.1
- milpa/plugin: ^0.1
- milpa/runtime: ^0.5
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- milpa/tool-runtime: *
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- milpa/mcp-server: Install to expose this app's tools over MCP — the agent-ready surface. `composer require milpa/tool-runtime milpa/mcp-server` (or: `php bin/coa agent:enable`).
- milpa/tool-runtime: Install to expose this app's tools over MCP — the agent-ready surface. `composer require milpa/tool-runtime milpa/mcp-server` (or: `php bin/coa agent:enable`).
README
Milpa Skeleton
composer create-project milpa/skeleton myapp→ an app that runs — booted, serving/, answeringcoa— with zero database. This is your starting point, not a demo.
milpa/skeleton is the smallest real host of milpa/runtime: a Kernel::boot() call, one
plugin, one route, one CLI. No Doctrine, no legacy Milpa\Web, nothing to configure before you
see it work.
Quickstart
composer create-project milpa/skeleton myapp
cd myapp
php -S localhost:8000 -t public
Open http://localhost:8000 — you'll see "Milpa is running", served by
App\Plugins\HelloPlugin\Controllers\HomeController through Milpa\Runtime\Http\RequestHandler.
The page points at the same first-five-minutes loop the CLI can print for you:
php bin/coa wow
The first-five-minutes path
Milpa's skeleton is intentionally small, but it is not a blind hello world. The "wow" is the closed evidence loop: create → inspect → extend → validate → expose to agents.
Start by asking the app what actually booted:
php bin/coa doctor php bin/coa inspect:routes php bin/coa inspect:commands
doctor should report the stock app's single plugin, single route, config value, and zero database
queries:
milpa · coa doctor
root: /path/to/myapp
✔ 1 plugin(s) configured, 1 booted: HelloPlugin
✔ container: Milpa\Container\DIContainer
✔ dispatcher: Milpa\Eventing\EventDispatcher
✔ 1 route(s) declared (RouteProviderInterface plugins)
✔ config: app.greeting = 'Milpa is running.'
✔ kernel booted — zero database queries.
Now make the smallest visible change and inspect it before trusting it:
php bin/coa make:controller DemoPlugin DemoController --path=/demo --register php bin/coa inspect:routes php bin/coa validate
Serve the app and hit the new route:
php -S localhost:8000 -t public curl http://localhost:8000/demo
When you want the agent surface, opt in explicitly:
php bin/coa agent:enable php bin/coa inspect:tools printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ | php bin/mcp-server.php
A stock app has no tools yet; that is honest. The point is that the app can now expose whatever
#[Tool] methods its booted plugins register, and both humans and agents can list that surface
before calling it.
What's in here
| Path | What it is |
|---|---|
public/index.php |
The HTTP entry point: builds a PSR-7 request from globals, boots the kernel, dispatches through Milpa\Runtime\Http\RequestHandler, emits the response. |
bin/coa |
The CLI entry point — doctor, validate, make:controller/entity/plugin/service/tool/crud, inspect:plugins/routes/services/tools/commands, agent:enable. See src/Console/Application.php. |
bin/mcp-server.php |
The generic MCP stdio server. Only serves once agent-ready is turned on (see below) — a stock app prints guidance and exits 0. |
config/plugins.php |
The active-plugins list — a plain list<class-string>. This is the only source of truth for what boots; no database, no filesystem discovery. |
config/app.php |
The app-config bag. Registered by Kernel::boot() as Milpa\Runtime\Config; plugins read it in boot(). See The Config idiom. |
src/Plugins/HelloPlugin/ |
The example plugin: #[PluginMetadata], no provides/requires, one GET / route, and the Config read that drives the homepage greeting. Copy its shape for your own plugins. |
tests/Boot/KernelBootTest.php |
The boot smoke test: the kernel boots from config/plugins.php and GET / returns 200. |
Agent-ready is opt-in
The stock app is minimal: composer create-project milpa/skeleton pulls no AI/MCP packages, and
require in composer.json lists none. bin/mcp-server.php and coa inspect:tools both still run
— they just report that the surface isn't on yet, cleanly, with no fatal.
Turn it on when you actually want to expose this app's tools over MCP:
php bin/coa agent:enable
# — a thin wrapper over —
composer require milpa/tool-runtime milpa/mcp-server
Either one installs milpa/tool-runtime (the #[Tool] attribute + registry) and milpa/mcp-server
(the JSON-RPC/stdio transport). Once installed, bin/mcp-server.php serves every #[Tool] a booted
ToolProviderInterface plugin registers, and coa inspect:tools lists them. coa make:tool prints
the same "not detected, run composer require milpa/tool-runtime" guidance if you scaffold a tool
before opting in.
Add a plugin
- Create
src/Plugins/YourPlugin/YourPlugin.phpimplementingMilpa\Interfaces\Plugin\PluginInterfacewith a#[Milpa\Attributes\PluginMetadata(...)]attribute (copyHelloPlugin's shape). - To contribute routes, also implement
Milpa\Runtime\Http\RouteProviderInterface::routes()— return alist<Milpa\Http\Routing\Route>, each bound to aMilpa\Http\Routing\HandlerReference(ControllerClass::class, 'method'). - Write a controller whose method takes a
Psr\Http\Message\ServerRequestInterfaceand returns aPsr\Http\Message\ResponseInterface(seeHomeController— this skeleton shipsnyholm/psr7as its PSR-7 implementation, sincemilpa/httpdeliberately ships contracts only, no concrete request/response classes). - Add the class to
config/plugins.php. php bin/coa doctorto confirm it booted and its routes were counted;php bin/coa validatefor a static pre-boot capability check without runningboot().
Milpa\Runtime\Kernel::boot() capability-checks every configured plugin's #[PluginMetadata]
before anything boots — a requires with no matching provides fails loudly, pre-boot, with
a typed PluginDependencyException, not a runtime surprise three requests later.
The Config idiom
A plugin's constructor is fixed by Milpa\Interfaces\Plugin\PluginInterface to a single argument
— (DIContainerInterface $container). It never receives config values directly. So how does a
plugin get a storage path, an API base URL, or a greeting string? It reads them in boot()
from the app-config bag:
-
Put configuration in
config/app.php, which returns a nested array. Dot-notation indexes it, so['app' => ['greeting' => 'Hi']]is read back asapp.greeting. -
public/index.php(andbin/coa) pass it into the kernel:$kernel = Kernel::boot([ 'plugins' => require $root . '/config/plugins.php', 'config' => require $root . '/config/app.php', ]);
-
Kernel::boot()registers it in the container asMilpa\Runtime\Config. A plugin reads what it needs inboot()— this is exactly whatHelloPlugindoes:public function boot(): void { $greeting = $this->container->get(Config::class)->get('app.greeting', 'Milpa is running.'); $this->container->registerService(HomeController::class, new HomeController($greeting)); }
Edit app.greeting in config/app.php, reload http://localhost:8000, and the heading changes
— no env vars, no constructor plumbing. That is the whole idiom: config lives in a file,
plugins read it in boot(), coa doctor echoes back the value it resolved.
Scaffolding with coa
bin/coa wires milpa/devtools' generate/inspect layer straight into this project — including
for an agent driving the CLI, not just a human:
php bin/coa doctor # boot the kernel, report what came up php bin/coa validate # static capability check, no boot() php bin/coa make:controller PingPlugin PingController --path=/ping --register php bin/coa make:entity BoardPlugin Task --fields="title:string:200,done:bool" --wire php bin/coa make:plugin BoardPlugin --provides=board.capability php bin/coa make:service BoardPlugin WorkflowService --interface php bin/coa make:tool BoardPlugin CompleteTaskTool --description="Mark a task done" php bin/coa make:crud BoardPlugin Task --fields="title:string:200,status:string:20" --register php bin/coa inspect:plugins # booted plugins + their capability graph php bin/coa inspect:routes # the booted route table php bin/coa inspect:services # what the DI container has registered php bin/coa inspect:tools # registered #[Tool]s (or "agent-ready not enabled") php bin/coa agent:enable # opt in: composer require tool-runtime + mcp-server
make:controller is real milpa/devtools machinery (Milpa\DevTools\Make\Generators\ControllerGenerator
WriteGuard), and it scaffolds code that boots in this skeleton unchanged. devtools auto-detects the convention per app root (Milpa\DevTools\Make\ConventionDetector): this project hasconfig/plugins.phpand anApp\PSR-4 root with nomilpa.json, so it picks the runtime flavor with no flag and writes exactly this skeleton'sApp\Plugins\*+RouteProviderInterfacepattern — a plain PSR-7 controller (index(ServerRequestInterface): ResponseInterface, no base class, no#[Route]) plus a minimalRouteProviderInterfaceplugin wiringGET <path> → Controller::index. By default the command prints the registration step so you can review the app boundary yourself; pass--registerwhen you wantcoato add the generated plugin class toconfig/plugins.phpfor you. Do that, reload, and the new route serves a real response. Pass--flavor=legacyto force the oldMilpa\Plugins\*+BaseControllerhost convention instead;--path=/routesets the route path.
The same Generator + WriteGuard engine backs make:entity (a persisting domain model +
FileRepository), make:plugin (a standalone composition unit), make:service (a DI-registered
domain service, optionally with a companion interface via --interface), make:tool (a
#[Tool]-attributed AI-callable method — requires composer require milpa/tool-runtime in your
own project to actually load), and make:crud (entity + a 5-method REST controller + routes +
wiring plugin, composed from make:entity plus a new controller shape). make:entity --wire is an
explicit convenience splice for an existing plugin that carries the // {coa:services} marker: it
inserts the repository registration for you, but it is intentionally honest that this is not a
design review — check whether that plugin is really the right composition boundary. Run php bin/coa
with no arguments for the full command/option reference. The inspect:* commands boot the real kernel and
report what they find — inspect:services reaches into the concrete
Symfony\Component\DependencyInjection\ContainerBuilder under Milpa\Container\DIContainer (the
DI contract itself exposes no enumeration method), and inspect:routes reconstructs the route
table from every booted RouteProviderInterface plugin (Milpa\Http\Routing\Router exposes no
route-table accessor of its own).
What this is NOT
- Not a finished app. One plugin, one route, one page. Everything past that is yours to add.
- Not wired to a database.
milpa/runtime's plugin registry is config-driven, never a Doctrine entity — persistence is something a plugin opts into, never something the kernel requires. Adddoctrine/ormand a storage plugin when (if) you need one. - Not the only PSR-7 choice.
nyholm/psr7is declared here as a real dependency becausemilpa/httpships routing contracts only, no concrete request/response implementation. Swap it for another PSR-7/PSR-17 implementation if you prefer —public/index.phpandRequestHandleronly depend on the PSR interfaces.
The family
This skeleton composes eight published Milpa packages, unmodified:
milpa/runtime— the bootable kernel that wires the rest togethermilpa/resolver— resolves the architecture before booting it: the report validates the graph, orders the boot, and turns failures into learnable errorsmilpa/core— contracts, capability graph, eventsmilpa/container— the DI containermilpa/events— the event dispatchermilpa/http— PSR-15-native routing contractsmilpa/plugin— the plugin contractsmilpa/devtools— the engine behindbin/coa
Contributing
Contributions are welcome. Please report security issues responsibly, and note that this project follows a standard code of conduct.
License
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.