# 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.