Core

controllers-and-routing

  • mmo.core
  • agent skill
  • mmoframework-skill://mmo.core/mmoframework/references/controllers-and-routing.md
# Controllers & Routing A controller is a singleton (`tSingleton`, via `aModule`) whose public methods, tagged with `#[APIRoute(...)]` (or `#[Command(...)]`/`#[InteractiveCommand(...)]` for CLI), become routes. `Application::connectRoutes($class)` (or `\MMO\connect($routes)`) extracts them via reflection and registers them. ## Base classes — pick the right one | Base | `onRoute()` behavior | Use for | |---|---|---| | `aController` | Just claims the route (`Route::setController`) — **no response is created for you** | routes that return an `iResponse` directly, or that implement their own `onRoute()` | | `aDataController` | Also does `$route->setResponse(Response::Json())` automatically | JSON API endpoints that just return plain arrays | | `aShellController` | CLI-oriented; built-in `route_help`, interactive REPL support | CLI commands (`#[Command]`/`#[InteractiveCommand]`) | **This is the thing to get right.** A route handler returning a plain array needs a response *already set on the route* before it runs, because the middleware loop merges an array return into the current response (`Route::addContent()`) rather than creating one from nothing: ```php // Extends aController: MUST return an iResponse, or set one up yourself class MyController extends aController { #[APIRoute('GET|greet/{name}.json')] public function routeGreet(Route $route): Response { return Response::Json(['greeting' => "Hello, {$route->get('name')}!"]); } } // Extends aDataController: plain array works out of the box (onRoute() already did Response::Json()) class MyDataController extends aDataController { #[APIRoute('GET|greet2/{name}.json')] public function routeGreet(Route $route): array { return ['greeting' => "Hello, {$route->get('name')}!"]; } } ``` If you extend plain `aController` and want array returns anyway, override `onRoute()` yourself — see `RepositoryController::onRoute()` for a real example that also branches on file extension (json/csv/xlsx) to pick the right response type and a download filename. Verified directly: a controller extending `aController` with no `onRoute()` override, returning a plain array, crashes with "route has no response set." ## `#[APIRoute]` pattern syntax ```php fragment #[APIRoute('GET|data/{repository}/{collection}.{extension}', 'description', mediaTypes: ['json', 'csv'])] ``` `METHOD|path/with/{params}.{extension}` — `{name}` segments become route params readable via `$route->get('name')` (checked in attributes/query/body/... order, see server-http.md). Query params can be declared too: `'GET|search.json?q={q}&limit={?limit}'` (`?` prefix = optional). `order:` orders the routes of the controller that handles the request (lower runs first, as for middlewares: every matching one runs). It does not choose between controllers: the one built first (normally the first listed in `routes`) claims the request in its `onRoute()`, whatever its routes' `order` — list a catch-all controller last. ## Wiring routes into the app ```php \MMO\config([ Application::class => [ 'routes' => [MyController::class, MyDataController::class], ], ]); ``` or directly: `Application::Shared()->connectRoutes(MyController::class)`. A class implementing `iRoutable` gets its static `ConnectRoutes(&$routes, $config)` called too, for programmatic route registration beyond what attributes alone can express (e.g. `RepositoryController::ConnectRoutes()` injects the actual configured repository/collection names as an enum constraint on every route's params). ## CLI commands ```php class MyCliController extends aShellController { #[Command([['const' => 'status']], 'show status')] public function route_status(Route $route) { echo "OK\n"; } #[InteractiveCommand('greet', 'say hello', [['type' => 'string', 'description' => 'name']])] public function route_greet(Route $route) { $name = $this->interactive ? \MMO\core\shell\ask('name?') : $route->get('1'); // 2nd shell arg (0 = command name itself) \MMO\core\shell\say("hello $name"); } } ``` Positional CLI arguments are read via `$route->get('1')` (0-indexed, `'0'` is the command name itself; the key is a string: `Route::get()` takes one, so an int is a TypeError in a `strict_types` file) — the request body for a `ShellRequest` is parsed into a mix of numeric-keyed positional args and named `-x`/`--flag` options by `\MMO\core\shell\parse()`. Several shell controllers can share one script (`'=routes' => [EdmsShellController::class, AdminController::class]`): each matches its interactive `index` for any command line, and the first controller claims the route. One that matched only its `index` while another matched a command steps aside, so the command runs; with no command at all the first one opens its REPL. To ask something, use `\MMO\core\shell\`'s prompts, not `fgets(STDIN)`: `prompt($question, $default, $validate)` (free text: shows `[default]`, ENTER keeps it, `-` clears it; `$validate` returns null, or why not), `promptList()` (comma-separated), `choose($question, [key => meaning], $default)` (numbered choices), `confirm($question, $default = true)`, `readSecret()`. Each returns **null when nobody answered** — the input ended, or an invalid answer came from something that is not a terminal: treat null as "cancel", never as the default. Without a terminal they read a line at a time, so a pipe or a file answers them, and a test does with `\MMO\core\shell\setInput($stream)` (a `php://memory` stream written beforehand). At a terminal each one only takes what is typed once it is on screen: keys typed ahead are dropped, and `confirm()` takes `y` without ENTER (a test types into a pseudo-terminal: core's `tests/functions/TestShellTerminal.php`). The output side: `heading()`, `explain()` (prose wrapped to the terminal, `backticks` highlighted), `success()`, and `diff($old, $new)` (a colored unified diff, to show before a file is written). mmo.admin's `SetupWizard` uses all of them.