The generate-verify-inspect developer loop for the Milpa PHP framework: deterministic plugin/controller/entity/CRUD/service/tool scaffolding (the Make engine), boot-time doctors, and architectural validators — capability graphs, plugin manifests, boundary rules — that run in-process, with Composer-safe root resolution.
milpa/devtools is what coa calls when you type coa:make, coa:doctor, coa:validate, or
coa:inspect — the engine behind that whole CLI surface, extracted so it runs the same in-process
whether it's driven by a real host app's console or by your own tests. Generate deterministic,
convention-following scaffolding; verify it against the exact runtime rules the framework
enforces; inspect a plugin ecosystem's capability graph and manifests for problems before they
become a boot-time failure. No exec() to a script at a hardcoded path, no assumption about
install depth — just classes you can new and call.
composer require milpa/devtoolsEntityGenerator/ControllerGenerator both target two conventions — a Doctrine Milpa\app
legacy host, or a plain milpa/data/PSR-7 milpa/runtime host — auto-detected per app root by
ConventionDetector (override with GenerationContext's flavor option, e.g. --flavor=runtime).
The full split, exact CLI syntax for each host, and the --fields DSL live in
docs/DEVTOOLS-MAKE.md of the host monorepo; this README's example
below shows the legacy flavor. Either way it's a string, in memory, with zero disk I/O of its
own (that's WriteGuard's job, so a caller can inspect, diff, or dry-run a generation before
anything touches the filesystem):
use Milpa\DevTools\Make\GenerationContext;
use Milpa\DevTools\Make\Generators\EntityGenerator;
$context = new GenerationContext(
plugin: 'InventoryPlugin',
name: 'Product',
options: ['fields' => 'name:string:120,price:decimal:10,2,active:bool'],
root: '/path/to/host-app',
);
$result = (new EntityGenerator())->generate($context);
echo $result->files[0]->path;
// -> /path/to/host-app/plugins/InventoryPlugin/Entities/Product.php
echo $result->files[0]->contents;produces (verbatim, this is a real run — see What's inside for the full field DSL):
<?php
declare(strict_types=1);
namespace Milpa\Plugins\InventoryPlugin\Entities;
use Doctrine\ORM\Mapping as ORM;
use Milpa\Support\UuidGenerator;
#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{
use UuidGenerator;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $price;
#[ORM\Column(type: 'boolean')]
private bool $active;
public function __construct()
{
$this->uuid = self::generateUuid();
}
// ... getId()/getUuid(), plus a get*()/set*() pair per field (elided here — see the
// generated file above for the full, real output).
}$result->verifyKind is 'entity' and $result->verifyTarget is the class's FQCN — the exact two
values VerifyRunner::run() needs to close the loop (see below) once the file is actually written.
The engine below is callable on its own, but a Milpa host normally adopts it as declared
operations — one Operation each for validate, make and test, enlisted once and materialised
by every surface the host projects to (terminal, HTTP, TUI, MCP). A host adds DevToolsOperations to
its operation providers and gets coa make … and a make tool its agent can call, from the same
declaration.
The three are a loop: write it, check it follows the convention, then run it to find out whether
it also does what it was supposed to. Without the third the loop closes on form and never on
behaviour — an entity can satisfy EntityInterface perfectly and still return the wrong field from
toArray().
coa make plugin Inventario Inventario --provides=inventory
coa make crud Inventario Producto --fields=sku:string,existencia:intmake takes what from six kinds — plugin, controller, entity, crud, service,
tool — plus the target plugin and the class name. With what=plugin the target is the artifact,
so both names are the same value. Everything else is optional and per-kind: --fields, --route,
--methods, --table, --provides, --requires, --interface, --needs, --tool-name,
--description, --flavor.
The result is a value, not a log line:
[
'ok' => true,
'files' => [
['path' => '…/Plugins/Inventario/Entities/Producto.php', 'action' => 'created'],
['path' => '…/Plugins/Inventario/Controllers/…', 'action' => 'created'],
['path' => '…/Plugins/Inventario/Inventario.php', 'action' => 'merged'],
],
'verify' => ['ok' => true, 'output' => '…'],
'guidance' => 'Auto-wired into the existing plugin at … (// {coa:services} marker found). …',
]Four actions, and each says something different: would-create (a dry_run plan, nothing touched),
created, merged (grafted into an existing file at its marker — not an overwrite), and
rolled-back (written, then undone because the verify failed). guidance is the generator's own
next step — "register it in config/plugins.php", "no // {coa:routes} marker, add the 5 REST
routes by hand" — and it is null when the write was rolled back, because pointing someone at
files that no longer exist is worse than saying nothing.
make declares mutating: true and does not require a signature: its blast radius is already
bounded by WriteGuard (never clobbers without --force) and by the rollback. --force overwrites
the artifact; it deliberately does not reach MarkerInserter, so forcing a regeneration can never
duplicate a service registration.
coa test --filter=ProductoTest['ok' => true, 'ran' => true, 'tests' => 12, 'assertions' => 34, 'failures' => 0, 'errors' => 0,
'output' => '…', 'command' => '…/vendor/bin/phpunit --colors=never --filter ProductoTest']The verdict is PHPUnit's exit code, not its text — the same thing a CI reads about the same run.
ran separates "the suite failed" from "the suite could not run" (no phpunit installed, a path
that escapes the project root): two different pieces of news, and confusing them means fixing the
wrong thing. Counts that cannot be read come back null, never 0 — zero tests and could-not-count
are different answers.
Unlike the other two, test declares surfaces: ['cli', 'tui', 'mcp']. A web request that fires the
app's own test suite is a surface nobody asked for: redundant in development, and a way to knock over
a deployed process from outside. It is also the one place in this package that spawns a subprocess —
a suite needs its own: inherited autoload state would make the result meaningless, and a fatal in one
test would take the caller down with it.
Every coa:* devtools command needs one thing before it can do anything else: the Milpa host
application's filesystem root — the directory holding its composer.json, plugins/,
scripts/. Computing that as dirname(__DIR__, N) from a command's own file only works while the
command lives at a fixed depth relative to the host; the moment this package is Composer-vendored
(vendor/milpa/devtools/..., any install depth, a global install), that walk lands under vendor/
instead — silently, since plugins/ or scripts/ just aren't found under the wrong root (or worse,
a same-named directory is found there instead).
RootResolver::resolve() tries three strategies, in order, and throws
RootNotFoundException — never a plausible-looking wrong path — if none of them lands:
- An explicit root passed to the constructor. Host wiring always wins — e.g. a container binding the app root once from a known-good source, or a test fixture.
Composer\InstalledVersions::getRootPackage()['install_path']. The Composer-canonical answer to "where is the application that required me" — correct regardless of install depth, path-repo dev install vs. a real registry install, valid the instant Composer's generated autoloader is on the include path, which it always is for any Composer-managed PHP process (composer-runtime-apiis a real dependency of this package, not an optional one — see Requirements).- Walk up from
getcwd()looking for the nearest ancestorcomposer.json— a last-resort fallback for the pathological case where Composer's own runtime API is unavailable.
use Milpa\DevTools\Support\RootResolver;
// Tier 1: explicit root wins outright.
(new RootResolver('/srv/my-milpa-app'))->resolve();
// -> '/srv/my-milpa-app'
// Tier 2 (no explicit root): Composer\InstalledVersions::getRootPackage()['install_path'].
(new RootResolver())->resolve();
// -> the absolute path of whatever application actually required milpa/devtools —
// correct whether that's a path-repo dev install or vendor/milpa/devtools in production.| Layer | Namespace | What it does |
|---|---|---|
| Generate | Make |
Six GeneratorInterface implementations — PluginGenerator, ControllerGenerator, EntityGenerator, CrudGenerator, ServiceGenerator, ToolGenerator — render a .php.stub template into a PlannedFile (path + contents, no I/O yet). The four composite ones also graft their wiring into an existing plugin at its // {coa:*} markers (MarkerInserter, idempotent: re-running does not duplicate the insertion), planning that file with PlannedFile::$merge so WriteGuard grafts instead of refusing. FieldParser reads the --fields DSL; WriteGuard refuses to clobber an existing file unless --force; VerifyRunner closes the loop by running the matching verifier against the freshly written class, in-process. |
| Verify | Verify |
ControllerVerifier / EntityVerifier reflect an already-autoloaded class and check it against the framework's real runtime conventions — extends BaseController, calls parent::__construct(), correct #[ORM\Column] nullability, no debug output, no duplicate routes, and more. A VerificationResult never throws for a violation; it collects errors (fail the run) and warnings (advisory only). |
| Inspect | Validators |
PluginManifestValidator checks one milpa.json against the plugin manifest shape. CapabilityGraphValidator checks an entire plugin ecosystem: every hard requires must be satisfied by some plugin's provides, and the dependency graph must be acyclic (unmet suggests degrade, they never fail). ProviderImplementsValidator autoloads every declared provider and asserts it really implements what it claims. BoundaryValidator runs host-supplied BoundaryRules (which directories may not reference which namespaces) — the engine is generic, the rules are yours. |
A validator example, real output — a manifest with a non-semver version fails with a precise,
addressable message instead of a generic "invalid manifest":
use Milpa\DevTools\Validators\PluginManifestValidator;
file_put_contents('/tmp/milpa.json', json_encode([
'name' => 'acme/inventory',
'version' => '1.0', // not semver — must be x.y.z
'type' => 'Mixed',
'namespace' => 'Milpa\\Plugins\\InventoryPlugin',
'entrypoint' => 'InventoryPlugin.php',
]));
$result = (new PluginManifestValidator())->validate('/tmp/milpa.json');
$result->ok(); // false
$result->errors; // ["version must be semver: '1.0'"]The generated code targets your host app's conventions, not this package's. ControllerGenerator
and ControllerVerifier both know the exact FQCNs Milpa\app\Providers\BaseController /
HttpResponse and the #[Route] attribute — that convention belongs to a real Milpa host
application, not to milpa/devtools itself, which ships zero use imports of those classes
(the .stub templates reference them as generated-code text, not real dependencies). coa:make controller scaffolds a class that targets those FQCNs; ControllerVerifier closes the loop by
checking generated output against that same convention.
| Namespace | What it provides |
|---|---|
Milpa\DevTools\Make |
GeneratorInterface, GenerationContext/GenerationResult/PlannedFile, the six generators — PluginGenerator, ControllerGenerator, EntityGenerator, CrudGenerator, ServiceGenerator, ToolGenerator (each targets Flavor::Runtime or Flavor::Legacy, picked by ConventionDetector; plugin is runtime-only), MarkerInserter/Markers (the // {coa:services}, // {coa:routes}, // {coa:tools} anchors composites graft into), FieldParser/FieldSpec (the --fields DSL: name:type[:mods], ? prefix for nullable, enum:<Enum>, <name>:belongsTo:<Target> — legacy-only), StubRenderer, WriteGuard, VerifyRunner |
Milpa\DevTools\Verify |
VerifierInterface, VerificationResult, ControllerVerifier, EntityVerifier |
Milpa\DevTools\Validators |
PluginManifestValidator, CapabilityGraphValidator, ProviderImplementsValidator, BoundaryValidator (+ BoundaryRule/BoundaryRuleResult/BoundaryReport) and each validator's typed result |
Milpa\DevTools\Support |
RootResolver/RootNotFoundException, ClassNameExtractor (file path → FQCN, no autoloading — lets a CLI accept either), ProcessRunner (the one subprocess this package spawns: output and exit code, with a deadline) |
Every public symbol carries a DocBlock; the full field DSL, every generator/verifier check, and every validator's exact error messages are documented at the source and in the API reference.
- PHP ≥ 8.3
milpa/data— a genuine runtimerequire: the runtime entity path (Milpa\Data\EntityInterface/RepositoryFactory— the scaffold picks its backend fromstorage.driver: file, sqlite, mysql or memory) is always loadable oncemilpa/devtoolsitself is composer-installed.milpa/corestill appears only inrequire-dev(docs tooling).doctrine/orm^3 — optional (suggest, notrequire): only the legacy entity path needs it —EntityGenerator::generateLegacy()andEntityVerifier's legacy branch reflect real#[ORM\Column]/#[ORM\JoinColumn]attributes, they don't just pattern-match their names. Generating/verifying a legacy entity without it installed fails fast with one clear message instead of a crash deep in attribute reflection. The controller path (either flavor) and the runtime entity path never touch Doctrine.composer-runtime-api^2.2 — the documented way to depend onComposer\InstalledVersions, whichRootResolveruses as its second resolution tier
Full API reference: getmilpa.github.io/devtools — generated straight from the source DocBlocks and dressed with the Milpa design system.
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.