# MMOFramework — coding guide for AI agents
MMOFramework (namespace `MMO\`, PHP 8.3+) is built from four cooperating layers: a DI/config container with
lazy singletons, an entity/repository/collection/query ORM, an HTTP/CLI routing layer with attribute-driven
controllers, and a plain-function event system. This is everything needed to write idiomatic code against it.
For live, app-specific detail, call this server's tools (`describe_class`, `list_entities`, `get_entity_schema`,
`list_routes`, `list_functions`, `describe_function`, `list_packages`) instead of guessing. When the
application has mmo.doc, `documentation_index` lists everything documented (every namespace, class, exception,
function, element and guide of every package), and `search_documentation` finds a symbol by name before
searching the text.
The example to copy is mmo.core's demo (`libs/core/demo`): an entity with validation, a JSON repository, a JSON
API, an html page from a template and an event, each file explaining itself.
This guide is the short version: the full one, with every trap confirmed by running code, is the
`mmoframework-skill://mmo.core/mmoframework/SKILL.md` resource and its `references/*.md` (and
`mmoframework-skill://mmo.client/mmo-client/...` for `.jhs` front-end files). Every skill is also a prompt of
this server, named after it: bugs of a release (a recette to turn into kanban tickets, tickets to fix, a
checklist to review before committing) follow `mmo-release-bugs` (`mmoframework-skill://mmo.admin/mmo-release-bugs/SKILL.md`).
MMOFramework is a monorepo-of-packages: this guide covers `mmo.core` itself, but a real application usually
also includes one or more `mmo-*` libraries (`mmo.client` for the front-end `.jhs` component system,
`mmo.oauth`, `mmo.pdo`, `mmo.mongodb`, `mmo.pdf`, ...), each with its own namespace under `MMO\` and its own
documentation. Call `list_packages` first to see which ones this application actually has, then
`resources/list`/`search_documentation` to read that package's own guide — do not assume mmo.core is the
whole framework. A package listed with `included: false` is only discovered: its documentation is served, but
its code is not loaded (`describe_class` cannot see its classes) until the application depends on it.
## 1. Singletons — never `new` them
Any class using the `tSingleton` trait (`Application`, `PackageManager`, `Cron`, `I18n`, and every controller)
is a per-request singleton — so is `Configuration`, which has a `Shared()` of its own:
```php
$app = \MMO\core\Application::Shared(); // NOT `new Application()`
MyController::Shared()->doThing();
```
`Shared()` lazily builds the instance the first time it is called, from whatever was loaded into
`Configuration` for that class name. `ClassName::Unshare()` discards the cached instance, first keeping its
state as the class's config (the next `Shared()` picks up where it left off); `Unshare(false)` keeps nothing.
## 2. Configuration / dependency injection
`Configuration::Shared()` is one nested `class-or-path => settings` array, populated via `\MMO\config([...])`
and read via `\MMO\get('Some/Path')`. A value shaped `['$' => SomeClass, ...args]` is a lazy object
descriptor, unserialized into a real instance the first time it is read:
```php
\MMO\config([
MyController::class => ['repositories' => ['main' => ['$' => '#repo']]],
'repo' => ['$' => \MMO\core\entities\JsonRepository::class,
'filenames' => ['items' => __DIR__ . '/data/items.json'],
'collections' => ['items' => MyEntity::class]],
]);
```
`#[Serialize(unserialize: 'setFoo')]` on a property tells the container to call `setFoo($value)` instead of a
plain assignment when an instance is built from config.
## 3. Entities — the ORM layer
An **Entity** is one record, a **Collection** holds every loaded entity of one class, a **Repository** owns
one or more collections and persists them.
```php
use MMO\core\entities\Entity;
use MMO\core\attributes\ArrayProperty;
/** @unique name */
class Article extends Entity {
#[ArrayProperty()]
protected string $name = '';
#[ArrayProperty()]
protected int $views = 0;
#[ArrayProperty(schema: '[string]')]
protected array $tags = [];
}
```
Rules that matter:
- Do not redeclare `id`, `label`, `title`, `status`, `ctime`, `mtime` — `Entity` already declares them with
fixed types; redeclaring with an incompatible type is a fatal error.
- `#[ArrayProperty()]` with no arguments on a plain `protected` property is normal and correct: the name,
getter, setter and schema type are all inferred from the property itself. Only pass `getter:`/`setter:`/
`schema:` to override that inference, or put the attribute on the *class* (with an explicit `getter:` and
no backing property) for a computed/derived field.
- There is no magic `__get`/`__set`. Always read/write through `$entity->get('name')`,
`$entity->set('name', $value)`, `$entity->push('tags', $value)`.
- `@unique name` (class docblock) tells `Collection::getOrCreateEntity()` which field(s) identify "the same"
entity; without it, `getOrCreateEntity()` always creates a new one. A compound key is **comma**-separated
on one line (`@unique firstName,lastName`); repeat the `@unique` tag on its own line for multiple,
independent alternative keys.
- Nothing is persisted until you call `\MMO\commit($origin)` — entities track their own dirty state
(`isChanged()`, `isCreated()`, `isRemoved()`, `isValid()`/`getErrors()`).
- An entity checks values as they are set **in dev mode only**: check user input with
`Person::CheckData($input, $errors)` before creating an entity (or `CheckData([...$person->toArray(), ...$input])`
before changing one): nothing is touched when it is refused. `@required` only means the key is present; use
`@minLength 1` for "not empty".
- Status lifecycle: `cStatus::{DRAFT,PENDING,ACTIVE,VALIDATED,FINAL,ARCHIVE}`, changed via
`$entity->setStatus(...)`; `archive()` (soft) then `removePermanently()` (hard, archived entities only) are
the two deletion steps.
```php
$repo = new \MMO\core\entities\JsonRepository(['articles' => '/path/articles.json'], ['articles' => Article::class]);
$col = $repo->getCollection('articles');
$a = $col->getOrCreateEntity(['name' => 'Hello']);
$a->set('views', $a->get('views') + 1);
\MMO\commit($someOrigin);
```
Query a collection with `$collection->query($filter, fields: [], sort: ['-createdAt'], limit: 10, offset: 0)
->getEntities()`: `getEntities()` runs it (through the repository, if one is set), while `dispatch()` only
notifies the query's listeners. Filters are `$`-prefixed (`$and`, `$or`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`,
`$regex`), `'path/to/field' => value` for equality.
## 4. Controllers & routing
A controller is a singleton whose public methods, tagged `#[APIRoute(...)]`, become HTTP routes.
```php
use MMO\core\classes\aDataController;
use MMO\core\attributes\APIRoute;
use MMO\core\server\Route;
class WidgetController extends aDataController {
#[APIRoute('GET|widgets/{id}.json', 'Get one widget', mediaTypes: ['json'])]
public function routeGet(Route $route): array {
return ['id' => $route->get('id')];
}
}
```
- Extend `aDataController` when every route just returns a plain array — its `onRoute()` already sets
`Response::Json()` for you.
- Extend plain `aController` only if you return an `iResponse` yourself, or override `onRoute()` — a bare
`aController` subclass returning an array crashes with "route has no response set."
- `$route->get($key)` checks, in this fixed order: attributes (path placeholders) → query → body → headers
→ cookies → files.
- Wire routes with `\MMO\config([\MMO\core\Application::class => ['routes' => [WidgetController::class]]])`
or `\MMO\app()->connectRoutes(WidgetController::class)`.
- `order:` on `#[APIRoute]` 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.
## 5. Schemas
`Schema::FromClass(SomeEntity::class)` derives a JSON-Schema description from `#[ArrayProperty]` attributes
**plus each property's own PHP docblock** — a property's doc comment doubles as its schema, so you rarely
need `#[ArrayProperty(schema: ...)]` at all:
```php fragment
/**
* the user's age
* @minimum 0
* @maximum 150
* @required
*/
#[ArrayProperty()]
protected int $age = 0;
/** @format email */
#[ArrayProperty()]
protected string $email = '';
```
Recognized property-docblock tags: a bare description line, `@type`/`@var` (overrides the inferred type —
see below), `@label`, `@title`, `@description`, `@order`, `@icon`, `@view`, `@store`, `@hidden`, `@noindex`,
`@required` (bare, `true`/`false`, or a comma-separated list of required property names on the *class*), and
any JSON-Schema primitive constraint as a tag: `@minLength`, `@maxLength`, `@pattern`, `@format`,
`@multipleOf`, `@minimum`, `@exclusiveMinimum`, `@maximum`, `@exclusiveMaximum`, `@minProperties`,
`@maxProperties`, `@minItems`, `@maxItems`, `@uniqueItems`, `@minContains`, `@maxContains`.
`@var` types (a `schema:` string takes the same): `integer|number|string|boolean|null` (or `int`/`float`/`bool`),
`?T` (nullable), `"a"|"b"` (an enum), a class (`\Full\Name`, or a name of the class's own namespace: `use` is not
read), `[T]` (a list), `{key: T, ...: T}` (an object, `{T}` a map) -- and phpstan's arrays: `list<T>`, `array<T>`,
`T[]`, `array<int, T>` are `[T]`, `array<string, T>` is `{T}`, `array<mixed>` a list of anything. In a docblock,
what the framework cannot check (`non-empty-string`, an imported class, `array{id: int}`) is left unchecked.
There is no `$ref`, `enum[...]`, `not()`/`anyOf()`/`oneOf()`/`allOf()`.
`Schema::Defs(...$classes)` builds a `$defs` block for several classes at once (this is what
`RepositoryController::routeSchemas()` returns). `Validator`/`Coercer` (used internally by the entity layer
and by `#[APIRoute]`'s body/query declarations) check and convert values against a schema.
Do not invent a `@hash`/`@uid`/`@internal` tag — despite appearing in some documentation drafts, they are
not implemented; `uid()` is computed from the collection's own uid plus the entity's `id`, and there is no
way to exclude a property from the schema other than `@hidden`/`@noindex` (which hide it from a UI/index,
not from the schema itself) or not tagging it `#[ArrayProperty]` at all.
## 6. Events
Every cross-cutting extension point is a class extending `Event`:
```php
class UserCreated extends Event {
public function __construct(object|string $origin, public readonly string $userId) { parent::__construct($origin); }
}
UserCreated::AddListener(fn(UserCreated $e) => null /* ... */, order: 0);
UserCreated::Send($origin, $userId);
```
An event whose listeners answer extends `Signal` (listeners call `setReturn()`, the sender reads
`getReturn()`); a listener is usually a module method tagged `#[Listen(UserCreated::class)]`, connected when the
module is first built (`Module::Shared()`).
Extension points you will actually touch: `Create` (`\MMO\create()`), `Fetch` (`\MMO\fetch()`), `Commit`
(`\MMO\commit()`), `Log` (`\MMO\info()`/`error()`/`warning()`/...), `DocumentInit`/`DocumentRender`.
## 7. Namespaced plain functions
Utility code is *not* static-class methods — it is plain functions grouped by domain and called fully
qualified: `\MMO\core\arrays\get($src, $path)`, `\MMO\core\strings\jsonEncode($data)`,
`\MMO\core\paths\basename($file)`, `\MMO\core\files\scanFolder($dir)`, etc. Use the `list_functions`/
`describe_function` tools to see exactly which helpers exist in a given namespace and their real signature —
do not assume one exists without checking.
## 8. Running shell/SSH commands
`MMO\core\server\ShellRunner` wraps either the local `Shell` or a remote `SSH` connection (anything
implementing `iShell`) behind one API returning a `ShellResult` (`->out`, `->err`, `->code`, `->isOk()`,
`->getLines()`, `->assert($origin)`) with a real exit code either way — prefer it over calling
`$shell->exec()`/`execSafe()` directly, whose failure reporting differs between the local and SSH
implementations. It also has dry-run support and the handful of filesystem primitives (`exists`, `read`,
`write`, `symlink`, ...) a deployment script needs to run identically locally or over SSH.
## 9. Sharp edges
- `??` binds looser than `===`/`&&`/`||` — always parenthesize: `$a === ($b ?? $c)`, never `$a === $b ?? $c`
(the latter's `?? $c` is permanently dead code, since a `===` result is never `null`).
- `$document->import($filename, $vars)` does a **literal** `file_exists()`/`require()` lookup against every
registered `addImportPath()` folder — no extension is added or assumed, so pass the real on-disk filename
verbatim, extension included if the file has one (`import('welcome.phtml', $vars)` for a file literally
named `welcome.phtml`). It also does **not** `extract()` `$vars` into local variables despite what an older
docblock claimed — read them inside the template as `$_VARS['key']`, never a bare `$key`.
- `HtmlPrinter` prints a string as markup: user input printed into an `HtmlDocument` is an XSS unless escaped.
In a template, `htmlspecialchars()` what you print; through the printer, pass `new PrintOptions(escape: true)`
(`$this->renderValue($value, new PrintOptions(escape: true))`); in a tree of nodes, hold it in an `XmlText`
(`new XmlNode('td', [], [new XmlText($name)])`); or give the whole document
`new DocumentOptions(print: new PrintOptions(escape: true))`, which leaves what a `<script>` or a `<style>`
holds as it is. `setTitle()` takes text and escapes it: do not escape a title first. Nor what you log: a
log's message and backtrace are always escaped, so log an untrusted value as it is.
- Printer and document options: pass `PrintOptions`/`DocumentOptions` (`MMO\core\printers`,
`MMO\core\documents`) rather than an array — a misspelled array key (`['escpae' => true]`) is silently
ignored, a misspelled named argument is an error.
- Several operators on one filter field must all match (`['age' => ['$gte' => 18, '$lt' => 65]]`); an unknown
operator is an error naming the right one. `Entity::CheckData()` rejects a key the entity does not have.
- `\MMO\core\shell\escape()` is **not** injection-safe: it single-quotes an argument (`'\''` for a quote
inside), but returns one that already starts and ends with `'` as it is, taken for already quoted —
`escape("'a'; touch pwned; echo 'b'")` comes back unchanged. Quote a value you do not control with PHP's
`escapeshellarg()`, or a `ShellRunner`'s `quote()` (for its target shell, local or SSH). `Process` takes one
command string, not an argument list: build it from fixed pieces and quote each value you interpolate. On
Windows, `escape()` only wraps in double quotes, which has caveats of its own (`%VAR%` expansion).
- Middleware/job `order`/`priority` runs ascending (lower first) everywhere in this framework.
## 10. Verifying your work
```bash
php -l path/to/File.php
```
If unsure how something behaves, write a five-line script (`require 'bootstrap.php';` then exercise the
class directly) and run it — most of this framework needs no server or database to test in isolation
(`JsonRepository` persists to a plain JSON file).