Core

entities

  • mmo.core
  • agent skill
  • mmoframework-skill://mmo.core/mmoframework/references/entities.md
# Entities: Entity, Collection, Repository, Query The data model layer. An **Entity** is one record; a **Collection** holds every loaded entity of one class; a **Repository** owns one or more named collections and handles persistence; a **Query** filters/sorts/paginates a collection (locally or, for a real repository, delegated to it — see the caveat at the bottom). ## Defining an entity ```php use MMO\core\entities\Entity; use MMO\core\attributes\ArrayProperty; /** * @unique name */ #[ArrayProperty(name: 'total', getter: 'getTotal')] // computed property: attribute goes on the CLASS, not the method class Article extends Entity { #[ArrayProperty()] protected string $name = ''; // NOT `title`/`label`/`status` -- Entity itself already declares // those with specific types (?string/?string/cStatus); redeclaring // them with an incompatible type is a fatal error, confirmed directly #[ArrayProperty()] protected int $views = 0; #[ArrayProperty(schema: '[string]')] // a typed array/list property protected array $tags = []; public function getTotal(): int { return $this->views; } // the method itself carries no attribute } ``` `#[ArrayProperty($name = '', $getter = null, $setter = null, $schema = [], $store = null)]` marks a property as part of the entity's serializable/queryable data. Put it **on the property** for a normal stored field, or **on the class** (naming a `getter:`, and no backing property) for a computed/derived one — confirmed directly: putting it on the method instead silently doesn't register the property at all (`->get('total')` returns `null`, and it's absent from `toArray()`). On a property, `getter`/`setter` default to the property itself; `setter: false` makes it readonly from the outside (`set()` leaves it unchanged; constructor data and `loadArray($data, [], true)` still set it). On the class, no `setter:` means it cannot be set at all. `EmbeddedEntity` is the base for a nested-but-not-independently-collection-managed sub-object (used the same way, minus repository/collection wiring). **A secret is `@hidden`** (a password's hash, a client secret, a token): ```php fragment /** @hidden */ #[ArrayProperty(setter: false, store: true)] protected string $passhash; ``` It is stored and loaded like any property, and code reads it (`$user['passhash']`), but `toArray()` never exports it -- not even named (`toArray(['passhash'])`, `?fields=passhash`), nor through an entity holding this one (`toArray(['owner/passhash'])`, an embedded owner in JSON) -- and an API query (`Route::extractQuery()`) cannot filter or sort on it: 400 Bad Request, or `?filters[passhash][$regex]=/^.2y/` would tell it one character at a time. mmo.pdo still SELECTs its column. Leaving the getter out instead (`getter: false`) hides it too, but a readonly property is then lost on reload. Until 2026-09 `@hidden` (and `schema: ['hidden' => true]`) was overwritten by the attribute's constructor, and mmo.oauth's password hashes were in every API response holding a user: `tests/modules/TestHiddenProperties.php`. Properties are **not** directly accessible as `$entity->name` — there's no magic `__get`/`__set`. Always use `$entity->get('name')` / `$entity->set('name', $value)` (or `push('tags', $value)` to append to an array property), confirmed directly; a property would only be `->prop`-accessible if explicitly declared `public` instead of `protected`, which is not the pattern used anywhere in this framework's own entities. ## Typing an array property A property's schema comes from `schema:`, else from its docblock's `@var`, else from its PHP type -- and a bare `array` is a **list** of anything: a string-keyed array (a map) set on it is not a list, so it is not set at all. Say what it holds, the way phpstan reads it or with `schema:`: ```php fragment /** @var list<string> the genres */ #[ArrayProperty()] protected array $genres = []; // the same schema as #[ArrayProperty(schema: '[string]')] /** @var array<string, int> the copies, by genre */ #[ArrayProperty()] protected array $copies = []; // a map: schema: '{integer}' ``` | `@var` | `schema:` | holds | |---|---|---| | `list<T>`, `array<T>`, `array<int, T>`, `iterable<T>`, `non-empty-list<T>`, `T[]`, `(A\|B)[]` | `'[T]'` | a list of `T` | | `array<string, T>` | `'{T}'` | a map of `T` | | `array<array-key, T>`, `array<int\|string, T>` | | either | | `array<mixed>`, `list<mixed>`, `array` | | a list of anything | - `?list<T>` and `list<T>|null` are nullable -- declare the property `?array` as well: a `?T` docblock on a property that cannot be null makes setting null a TypeError (phpstan reports it: `property.phpDocType`). Lists nest (`list<list<int>>`, `array<string, list<int>>`); the text after the type is the property's description. - The items are checked: `CheckData(['genres' => ['math', ['ai']]], $errors)` refuses it (`not a valid [array].`), and so does `validate()` once it was set (see Validating). - What phpstan knows and the framework does not is left unchecked, never an error: `non-empty-` (add `@minItems 1`), `non-empty-string`, `positive-int`, `class-string`, a template's `T`, an array shape (`array{id: int}` is any array). A class is looked up in the entity's own namespace only -- `use` is not read: an imported class is left unchecked, so write `\Full\Name` to have it checked (a `\Full\Name` that does not exist is an error). In `schema:`, a short name is looked up in the global namespace, and one that is not there is an error: write `schema: '[\App\Genre]'`. - Until 2026-09, `list<string>` was read as `string` (a list could not be set, and was lost without a word), `string[]` made the class fail to load, and `array<...>` left its items unchecked and `<...>` in the description: `tests/modules/TestPhpstanArrayTypes.php`. ## Building entities: standalone or via a repository **Standalone** (just an object, no persistence): ```php $a = new Article(['name' => 'Hello', 'tags' => ['a', 'b']]); ``` **Via a repository** (the normal case for anything that needs to be saved/queried): ```php use MMO\core\entities\JsonRepository; $repo = new JsonRepository(['articles' => '/path/to/articles.json'], ['articles' => Article::class]); $collection = $repo->getCollection('articles'); // one of the collections given as the 2nd argument $a = $collection->createEntity(['name' => 'Hello']); // always creates a new one $a = $collection->getById($id); // null if not found ``` `getOrCreateEntity($data)` finds-or-creates — but "finds" only works if the class declares which property (or property group) counts as unique, via a `@unique propName` (or `@unique propA, propB` for a compound key) docblock annotation **on the class**: ```php fragment /** @unique name */ class Article extends Entity { ... } $collection->getOrCreateEntity(['name' => 'Hello']); // 1st call: creates $collection->getOrCreateEntity(['name' => 'Hello']); // 2nd call: returns the SAME entity (matched on 'name') ``` Without a `@unique` annotation, `getOrCreateEntity()` **always creates a new entity** — there is no generic "match by any overlapping field" fallback. Confirmed directly: omitting the annotation makes even `getOrCreateEntity()` called twice with identical data return two different entities. ## Saving changes: the Commit pattern Entities track their own dirty state. Nothing is persisted until you commit: ```php $a->set('name', 'New name'); \MMO\commit($someOrigin); // dispatches a Commit event; every registered collection with changed // entities gets asked to persist them ``` Check state before/after: `$entity->isCreated()`, `$entity->isChanged()`, `$entity->isRemoved()`, `$entity->isValid()` / `$entity->getErrors()`. `$entity->ignore()` flags the entity so that **every** later commit skips it; its values are not restored (it stays changed in memory, and a later valid change to it is not saved either). That is fine at the end of a request, not in a long-running process (tests, a websocket, cron): prefer checking with `CheckData()` before touching an entity (see Validating). `RepositoryController::routePost()` uses `ignore()` to skip archived/final entities. **What a client sends back is not all written** (`PUT`/`POST data/...`, i.e. `loadArray($data, [], true)`): `ctime` and `mtime` are dropped (the server sets them; only `loadFromStore()` and the constructor restore stored ones), and a linked, stored entity given as an array with an `id` or a `uid` is only referenced -- its copy is not loaded into it, so it is changed by its own save alone. An `EmbeddedEntity` given by `uid` is still updated in place, and an array without an id still creates the linked entity. Until 2026-10 both were written: a form kept the old mtime, and saving a declaration wrote the contract it links to over the stored one (`tests/modules/TestEntitySaveFromClient.php`). ## Validating Check input **before** it touches an entity, with `Entity::CheckData()`: it builds a throwaway instance from the data and validates it, so a rejected request leaves nothing behind (nothing created, nothing changed, nothing to undo): ```php fragment $errors = []; if (!Person::CheckData($input, $errors)) { // a new person return ['errors' => array_map(fn($e) => $e->message, $errors)]; // property => 'not a valid [email].', ... } $person = $people->createEntity($input); \MMO\commit($this); Person::CheckData([...$person->toArray(), ...$input], $errors); // a change to an existing one ``` - `$entity->validate($errors)` is what it runs: the whole schema, **plus every value that could not even be set** (`age=abc` can't become an int: the property keeps its previous value, so the schema alone would pass it). Errors are `ValidatorError`s: `->message` is plain text, and they convert to it as strings. - `CheckData()` also rejects a key that is none of the entity's properties (`emial` → `unknown property. did you mean [email]?`): loaded, it would be ignored and its value lost. Keep only the fields a request may set first (the demo controllers' `FIELDS`), so a client's extra field is not an error. - **An entity only checks values as they are set in dev mode** (`setArrayProperty()`, `MMO_MODE === MODE_DEV`): never rely on `isValid()` after `set()` in production. `validate()`/`CheckData()` work in every mode. - The property docblocks are the rules: `@minLength 1` (non-empty), `@format email`, `@minimum 0`... (`@required` only means the key is present). - `demo/src/controllers/PeopleController.php` and `ProductsController.php` are the complete examples. ## Lifecycle status `cStatus` enum: `DRAFT → PENDING → ACTIVE → VALIDATED → FINAL`, plus `ARCHIVE` (off to the side, not really "further along"). Helpers on every entity: `isDraft()`, `isPending()`, `isActive()`, `isValidated()`, `isFinal()`, `isArchive()`. Change status with `$entity->setStatus(cStatus::VALIDATED)` (returns `bool` — `false` only when it already had that status: no transition is ever refused). `$entity->archive()` / `$entity->removePermanently()` are the two "deletion" operations — archive first (soft), permanently-remove only an already-archived entity (hard). ## Querying ```php $query = $collection->query( ['$and' => [['status' => ['$in' => ['active','validated']]], ['author' => 'marc']]], fields: [], // [] = all fields; or e.g. ['title', '-body'] to hide 'body' sort: ['-createdAt'], // '-' prefix = descending limit: 10, offset: 0, ); $entities = $query->getEntities(); // runs it (through the repository, if one is set) ``` Or build it incrementally: `$query->filter([...])`, `$query->sort('field', true)`, `$query->field('name', false)` (hide it), `$query->pagination($limit, $offset)`, `$query->listOnly()` (only fetch ids). A filter can also be a string expression, parsed by `Filter::From()`: `$collection->query('age >= 18')`, `query('kind = "a"')` (text values quoted). **Filter syntax** (also usable standalone via `Filter::From($data)` → an `iFilter`, e.g. for `$entity->test($filter)`): operators are `$`-prefixed keys: `$and`, `$or`, `$nor`, `$in`, `$nin`, `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$regex`, `$exists`, `$has`, `$text`. A plain `'field' => 'value'` is an equality check; nested keys use `/` (e.g. `'metadata/ctime'`). - Several operators on one field must all match: `['age' => ['$gte' => 18, '$lt' => 65]]` is a range (so is `?filters[age][$gte]=18&filters[age][$lt]=65` through `Route::extractQuery()`). - A group can sit next to fields: `['name' => 'Ada', '$or' => [['age' => 20], ['age' => 30]]]`. - An unknown operator is an error that names the right one (`unknown operator [$gtee] on [age]. did you mean [$gte]?`), never a value to compare with. - `$exists` asks whether a field holds a value (null does not), and its value is a bool in any form: `true`/`1`, or a query string's `1`/`true` and `0`/`false` (`?filters[age][$exists]=0` finds those without an age). mmo.pdo makes it `IS [NOT] NULL`, mmo.mongodb `$ne`/`$eq: null`: MongoDB's own `$exists` counts a stored null. Until 2026-09 only a php bool matched, and on MongoDB '0' or 'false' was a string, so true. > **Caveat — `Query::$total`**: for the base `Repository`/`Collection` execution path, `$query->total` only > counts entities actually *returned* (i.e. bounded by `limit`), not the true count of every match across the > whole collection, despite a docblock elsewhere claiming otherwise. Don't rely on it for "total matching > records" (e.g. computing page counts) without checking — this is a known, currently-unresolved architectural > gap. See gotchas.md. ## Typed collections (generics) `Collection`, `iCollection` and `Query` are generic over the entity class (`@template T of Entity`), so phpstan (and your reader) knows what comes out of them when the class is known: ```php fragment $people = new Collection(Person::class); // Collection<Person> $person = $people->createEntity(['name' => 'Ada']); // Person $found = $repo->getCollectionByClass(Person::class)?->findOne(['name' => 'Ada']); // ?Person ``` Where the class can't be inferred (a collection fetched by name, a property), say it: `/** @var Collection<Person> $people */`. Left unsaid, `T` is plain `Entity`, and only `Entity`'s own methods are known on what comes out. ## Ids A `JsonRepository` numbers its entities (`getId()` is an int), but `Collection::getById()` takes a string: pass `(string)$id` — in a `strict_types` file (every new one) an int is a TypeError. An id read from a url (`$route->get('id')`) is already a string. It never gives an id twice: once the entity 12 is removed, the next one is 13, in this request or a later one (the file keeps the highest id it gave when its entities no longer tell it: `"$lastId": 12`). What is keyed by an id (a scope, a link, a cache) never applies to another entity. Until 2026-09 the id of the entity removed last came back. ## Repository specifics `JsonRepository` (extends `Repository`) persists each collection to a single JSON file, given as `['collectionName' => '/path/to/file.json']` to the constructor, with its collections as the second argument (`['collectionName' => Entity::class]`). `Repository::createEntity($class, $data, $collection)`, `::dropCollection($name)`, `::generateId($collection)` are the other repository-level entry points; most code goes through the `Collection` object (`$repo->getCollection($name)`) instead. An entity's uid names its repository's folder with a tag, not its path (the uid is in every API response): `file://3f2a9c1b/users.json#2`, the same in every request, so `\MMO\fetch($this, $entity->uid())` finds it even when another repository holds a `users.json`. The files name the entities they hold by their file's name alone (`<file://users.json#2>`), as they always did: the repository reading a file answers its references first, and a folder can move. An entity removed (`removePermanently()`, then `\MMO\commit()`) leaves its collection: `getById()` no longer finds it. See [gotchas.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fgotchas.md#a-jsonrepositorys-uids-removals-and-ids).