The administration panel of the Milpa PHP framework — a section shell that discovers what your app can actually do, server-rendered, no JavaScript required.
Add one plugin to your app and /milpa/admin exists: a navigation, a settings form, a plugin
manager, and a route inspector — behind your own auth, rendered on the server.
composer require milpa/admin// config/plugins.php
return [
Milpa\Admin\AdminPlugin::class,
// ...
];The panel knows no section by name. It asks every booted plugin for its sections and renders what it gets back:
final class BillingPlugin implements PluginInterface, AdminSectionProvider
{
public function adminSections(): array
{
return [new AdminSection('billing', 'Facturación', '/milpa/admin/billing', 40)];
}
}That is the whole extension point. Your section appears in the navigation, in order, with no change
to this package — and the same list drives the terminal shell (coa:admin, coa:tui), so a
section you add is a section you can also inspect without a browser.
| Section | What it does |
|---|---|
| Settings | The site configuration, as a form generated from the schema of a governed tool — with CSRF, validation, and redisplay of what you typed when it is rejected. |
| Plugins | What your app has, what boots, and a button per row. It drives milpa/plugin's operations, so the panel and coa plugins.list cannot disagree. |
| Sistema | The route table, read-only. |
Nothing is assumed and nothing is faked. A capability you did not wire simply does not appear — there are no dead controls (that is a rule, not a habit: see ADR-0005, surface honesty).
| You register | You get |
|---|---|
SessionStore + milpa/auth's scope middleware |
The panel at all — every section is behind milpa.admin. |
PluginRegistryInterface |
The Plugins section: list, enable, disable. |
PluginInstallerInterface |
Install, update and remove on top of it. Without it those three operations do not exist, so no surface renders a button that fails when pressed. |
RouteTableSource |
The Sistema section. Every host builds its route table differently; this port is how yours gets in. |
StorageRootSource |
Where the panel keeps its settings. Required if you use the Settings section — see below. |
The panel writes one file, <your storage root>/milpa-admin/settings.json, and it will not guess
where that root is:
final class MyStorageRoot implements Milpa\Admin\Contracts\StorageRootSource
{
public function storageRoot(): string
{
return __DIR__ . '/../storage'; // wherever YOUR app keeps mutable state
}
}Register it in the container before the panel boots, and AdminPlugin::boot() picks it up. If
nothing is registered, reading or writing settings throws with the name of the port it needs —
MILPA_ADMIN_SETTINGS_PATH also overrides the whole path if you want to point at one exact file.
Until 0.2.0 the path was computed by counting directories up from the package's own source file.
That worked while the code lived inside a host and broke the moment it did not: installed through
Composer it resolved to somewhere inside vendor/, a directory the next composer install can
delete. A package cannot know the root of whoever installs it — so now it asks.
Every control is a real <form> with a real submit. The panel enhances with JS when it is there and
works identically when it is not — which matters most at the exact moment you need it: turning off
the plugin that broke the page is not the time to depend on that page's JavaScript.
Two more decisions worth knowing:
- Installing is not consenting to run. A freshly installed plugin arrives disabled.
- A plugin declared in your code cannot be removed from the panel — it would delete files your own source still names. The panel says so, and offers to disable it instead.
milpa/console can expose any declared operation over HTTP, but it deliberately knows nothing about
who is calling: identity sits behind its OperationHttpPolicy interface. This package publishes the
implementation that uses milpa/auth, because it already requires it.
use Milpa\Admin\Http\AuthOperationHttpPolicy;
use Milpa\Console\Http\HttpProjector;
new HttpProjector($operations, $container, $psr17, $psr17, policy: new AuthOperationHttpPolicy($container));An operation typed by scopes goes through RequireScopeMiddleware and then the same PolicyGate
that guards MCP; one typed by permission goes through RequirePermissionMiddleware. Denied is a
401 or a 403 with the code that names it. A protected operation on a host that wired no auth
chain is a 500, never a 4xx — the caller did nothing wrong.
- PHP ≥ 8.3
milpa/core^0.6 ·milpa/auth^0.3 ·milpa/command^0.3 ·milpa/data^0.2milpa/http^0.1.5 ·milpa/http-symfony^0.1 ·milpa/runtime^0.7milpa/plugin^0.5 — the operations the Plugins section drivesmilpa/live-web^0.2 ·milpa/live-tui^0.3 ·milpa/tool-runtime^0.9
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.