Core

How to code with MMOFramework

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