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