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