Core

schemas-and-events

  • mmo.core
  • agent skill
  • mmoframework-skill://mmo.core/mmoframework/references/schemas-and-events.md
# Schemas & Events Two independent but frequently-paired subsystems: JSON-Schema-flavored validation/coercion, and a plain-function pub/sub event system used throughout the framework for extension points. ## Schema / Validator / Coercer `Schema` is a JSON-Schema-like description (`iSchema`), auto-derived from `#[ArrayProperty]`-tagged entity classes but also usable standalone. ```php $schema = Schema::From(['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]); $schema = Schema::FromClass(Article::class); // derive from an entity's #[ArrayProperty] attributes $schema = Schema::FromEnum(cStatus::class); ``` `Validator` checks a value against a schema; `Coercer` converts loosely-typed input (e.g. from a query string or JSON) into the schema's declared types. Both are used internally by the entity layer (`ArrayProperty`'s own schema). `#[APIRoute]`'s `body:`/query declarations only decide whether a route **matches**: they never answer with the reasons a value was refused — validate yourself (`Entity::CheckData()`, see entities.md "Validating"). Conversions, confirmed by running them (`Schema::From(['type' => ...])->coerce($value)`): - `integer`: `null` → `0`, `'1e3'` → `1000`; `'12.5'`, `''` and `true` are refused; - `string`: `null` → `''`, `true` → `'1'`, `12` → `'12'`; - `boolean`: `'false'` and `'0'` → `false`; - a nullable type (`['null', 'integer']`, i.e. `?int`): `null` and `''` → `null`. What is checked, confirmed by running it: - `required` means the **key is present**, nothing more: an empty string passes. For "not empty", use `minLength: 1` (`@minLength 1` in an entity property's docblock). - `format` is checked for the formats `\MMO\core\schemas\validateFormat()` knows (`email`, `uri`, `date`, `datetime`, `phone` — digits with spaces, dots or dashes —, `ipv4`, `hostname`, `color`...); an unknown format passes, and so does JSON Schema's `date-time` (the framework's name is `datetime`). (It was never checked before 2026-09: `StringValidator::KEYS` left `format` out.) - Each error is a `ValidatorError`: `keywordLocation`, `message` (plain text, e.g. `smaller than [0].`, `not a valid [null|string].`), and `instanceLocation` in its `jsonSerialize()` form. `validate()` fills its `&$errors` keyed by the failing value's key. ```php $errors = []; if (!$schema->validate($value, $errors)) { foreach ($errors as $key => $error) {echo "$key: {$error->message}\n";} } ``` `Schema::Defs(...$classes)` builds the `$defs` block of a JSON-Schema document covering multiple classes — this is what `RepositoryController::routeSchemas()` / `data/schemas.json` returns. ## Event system A listener is usually a method of a module or controller, tagged `#[Listen(UserCreated::class)]`: it is connected when that object is built from the configuration (its first `Shared()`), so build it before the event can be sent. The core demo does exactly this (`demo/src/Welcome.php`, built in `demo/www/index.php`). Every event is a class extending `Event` (or using `tEvent` directly), dispatched to registered listener callables. **An event whose listeners answer extends `Signal`** (itself an `Event`): only a `Signal` has `setReturn()`/`getReturn()`/`hasReturn()`. A plain `Event` just notifies. This is how the framework wires cross-cutting concerns (logging, autoloading, config creation, package inclusion, commits) without hardcoding dependencies between unrelated classes. ```php class UserCreated extends Signal { public function __construct(object|string $origin, public readonly string $userId) { parent::__construct($origin); } } UserCreated::AddListener(function (UserCreated $e) { echo "welcome {$e->userId}\n"; }, order: 0); // lower order runs first $event = UserCreated::Send($originObject, $userId); // constructs + dispatches in one call // or: (new UserCreated($origin, $userId))->dispatch(); if ($event->hasReturn()) { $value = $event->getReturn(); } ``` A listener of a stoppable event (one implementing `iStoppableEvent`, as `Create`, `Fetch` and `Inc` do — a plain `Signal` does not) can call `$event->stopPropagation()`, or `$event->setReturn($value, true)` to hand back a result and skip the remaining listeners. `setReturn($value)` alone skips nothing: later listeners still run (and may overwrite it) — which is why `Configuration`'s `onCreate()` (order 100) fulfills a `Create` event only if nothing earlier already set a return value (`hasReturn()`), letting listeners with a lower order win by running first. ### Events you'll actually encounter | Event | Fired when | Useful for | |---|---|---| | `Create` | `\MMO\create($origin, $class, $args)` | supplying a custom instance for a given class, bypassing `new` | | `Fetch` | `\MMO\fetch($origin, $href)` | resolving a reference/URL/id into a real value | | `Commit` | `\MMO\commit($origin)` | persisting every entity with pending changes | | `Log` | `\MMO\info()`/`error()`/etc. | capturing/routing log output (see `Application::addLog()`) | | `Autoload` | an unknown class is referenced | loading a class file lazily (see xlsx's `Autoload::OnNamespace`) | | `Inc` | a package is included (`PackageManager::inc()`) | hooking into package inclusion | | `DocumentInit` / `DocumentRender` | a `Document` is constructed / about to render | injecting shared page chrome, assets, etc. | `AddListener`/`RemoveListener`/`HasListener` are all static, scoped per event **class** (not per instance) — `MyEvent::AddListener($fn)` registers `$fn` for every future `MyEvent`, not one specific one.