Core

gotchas

  • mmo.core
  • agent skill
  • mmoframework-skill://mmo.core/mmoframework/references/gotchas.md
# Gotchas Every entry here is a *confirmed* bug or trap — established by actually running code, not by reading it — found during a full pass over the framework's source. Some have been fixed (noted); some are real, open issues that need a design decision before they can be fixed (noted, with the reasoning for why). Read this when something behaves unexpectedly, or before writing code that resembles one of these patterns yourself. ## `shell\escape()` does not protect an argument that already starts and ends with `'` **Corrected 2026-09-27, confirmed by both reading the current source and actually running injection payloads through it.** `\MMO\core\shell\escape()` (`src/functions/shell.php`) wraps a non-Windows argument in **single quotes**, with embedded single quotes escaped via the standard `'\''` trick (`"'" . str_replace("'", "'\\''", $arg) . "'"`). POSIX shells do **not** perform `$(...)`/backtick substitution inside single-quoted strings, so `$(touch pwned)` and a backtick payload come through inert. **But an argument that already starts and ends with `'` is returned as it is** (taken for one already quoted), so it is not protected: `escape("'a'; touch pwned; echo 'b'")` comes back unchanged, and passing the result to `\MMO\core\shell\exec('echo ' . $escaped)` ran the `touch`; through `Process` (which runs `exec <command>`, so only the first command of a `;` list runs) `escape("'a' $(touch pwned) 'b'")` still did. A `'; touch pwned; echo '` breakout comes through inert only by chance: returned as it is, it happens to be one single-quoted string. For a value you do not control, use PHP's `escapeshellarg()` (what `ShellRunner::quote()` does for a local shell): it neutralized every one of these payloads, through both. An earlier version of this skill said escape() wrapped in double quotes (every `$(...)` exploitable) — accurate for an older revision of the framework, **no longer true**; don't trust that claim (nor a claim that escape() is injection-safe) if you see it repeated elsewhere (old commit messages, cached context, etc.) without re-checking `src/functions/shell.php` yourself. Two more things `escape()` does **not** cover, still worth care: - The **Windows** branch (`$isWin`) still only wraps in double quotes with `"` escaped — `cmd.exe`/PowerShell quoting has its own footguns (e.g. `%VAR%` expansion) that single-quote-style escaping doesn't address. Don't assume Windows-side calls are equally safe without checking separately. - `escape()` only protects **one argument you pass through it**. Code that builds a shell string by concatenating *unescaped* pieces (flags, multiple filenames joined as one pathspec string, etc.) is exactly as unsafe as ever — `escape()` isn't magic dust you sprinkle over an entire command line. `Process` takes one command string (no argument list): build it from fixed pieces, and quote each value you interpolate with `escapeshellarg()` (or `ShellRunner::quote()`, which quotes for the runner's target shell, local or remote). ## `{root}/src/...` in a configuration works in a checkout and breaks in a release A configuration path into your own code is `{YourNamespace}/...`, **never** `{root}/src/...`. A release is built with `git archive`, `.gitattributes` export-ignores `src/` (`src/ export-ignore`), and the code runs from `packages/<name>.phar` instead — so a release has no `src/` folder at all, and mmo.admin's `AssertArtifactTask` refuses an artifact that contains one (`src/**` is in its `FORBIDDEN` list). `{root}/src/pages` therefore resolves to nothing the moment you deploy, while testing perfectly on your machine. The alias comes from the package's `src/bootstrap.php`, which returns ```php fragment return [\MMO\core\Application::class => ['paths' => [__NAMESPACE__ => __DIR__]]]; ``` so `{AcmePortal}` is `src/` in a checkout and the same folder inside the phar in a release: one value, right in both. Note the JSON spelling of a namespaced alias needs its backslashes escaped (`{MMO\\client}`). Do not reach for the environment to patch around it either — the mode follows the tree, not the host name: `MMO_MODE` is `dev` when `src/` exists and `prod` otherwise, which holds on a laptop, on a server and on the command line alike. ## `??` vs `===`/`&&`/`||` precedence `??` binds **looser** than `===`, `&&`, and `||`. This means: ```php fragment $a === $b ?? $c // parses as: ($a === $b) ?? $c ``` Since a `===` comparison is never `null`, the `?? $c` fallback is **permanently dead code** — this exact pattern caused multiple real, shipped bugs in the framework (a route-lookup fallback that never fired, a JSON-vs-urlencoded content-type sniff that was always true). Always parenthesize explicitly when mixing `??` with any comparison/logical operator: `$a === ($b ?? $c)`. ## `foreach` loop variables shadowing parameters ```php fragment function findJob(callable $job): ?callable { foreach ($this->jobs as $job) { // BUG: overwrites the $job parameter if ($job[1] === $job) {...} // now comparing an array's own element against itself } } ``` If a method parameter and a `foreach` loop variable share a name, the loop **silently overwrites the parameter** for the rest of the function — the original argument is gone. This caused real methods to always return `null` regardless of input. Give loop variables a distinct name (`$info`, `$item`, `$entry`) whenever the collection being iterated could plausibly share a name with a parameter — cheap to do, and this class of bug is otherwise invisible on read-through. ## `array_search()` + `?:` silently fails for a falsy match ```php return array_search($needle, $haystack, true) ?: null; ``` If the found key/value is itself falsy (`0`, `''`, `'0'`, `false`), `?:` treats a genuine match the same as "not found" and returns `null` anyway. Use an explicit `!== false` check instead: ```php return (($key = array_search($needle, $haystack, true)) !== false) ? $key : null; ``` ## PHP weak typing silently coerces `false`/mismatched types at return boundaries A method declared to return `string` but with a `return false;` branch doesn't throw a `TypeError` under weak typing — PHP coerces `false` to `''`. This is usually harmless *if every caller checks loosely* (`!$result`), but it means the literal `=== false` check the code's own author seemingly intended never matches. If you widen a return type for clarity (`string|false`), check first whether the method **overrides a parent/interface method** — PHP requires covariant return types on overrides, and widening there is a hard class-load-time fatal error, not just a warning (confirmed directly). Narrower interfaces sometimes have to stay technically-imprecise, documented instead of fixed. ## Extension-less template filenames Covered in [documents-and-printers.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fdocuments-and-printers.md#templates) — `Document::import('name')` looks for a file literally named `name`, no extension. Easy to trip over when writing a quick template. ## `Query::$total` isn't the true collection-wide count Covered in [entities.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fentities.md#querying) — for the base `Repository`/`Collection` path, it's actually "count of entities returned in this page," not "count of all matches." Don't use it for computing total page counts without verifying this has been fixed. ## Middleware/job execution order is ascending, not descending Both `Route::addMiddleware($fn, $params, $order)` and `Application::addJob($trigger, $job, $when, $priority)` run **lower `$order`/`$priority` first** — the opposite of what "priority" often implies in other frameworks. Verified directly by observing real execution order. ## A controller extending `aController` (not `aDataController`) needs to build its own response Covered in [controllers-and-routing.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fcontrollers-and-routing.md#base-classes--pick-the-right-one). Easy to forget when copying a route handler from one controller to a differently-based one. ## PHP method calls are case-insensitive — don't be surprised, but don't rely on it either `Stream::TEXT(...)` really does call `Stream::Text(...)`; `self::MailTendency(...)` really does call `$this->mailTendency(...)` from an instance context. This is legitimate PHP behavior, confirmed directly (not a bug), but it's also not something to write deliberately — if you see mismatched casing like this in the codebase, it's cosmetic, not a clue that something's broken. ## Entity validation on set runs in dev mode only `aEntity::setArrayProperty()` validates each value against its property's schema only when `MMO_MODE === MODE_DEV`. Code that relies on `isValid()`/`getErrors()` after setting values therefore passes everything in production. Check input with `Entity::CheckData($input, $errors)` before creating or changing an entity — see [entities.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fentities.md#validating). (Until 2026-09, a value that could not be converted was not reported by `validate()` at all, and its exception — trace and server paths included — was exported with the entity's metadata in API responses.) ## The interfaces are the contract — except `iSchema` `iEntity`, `iCollection` and `iRepository` declare everything the framework calls on them (widened in 2026-09 to match `aEntity`, `Collection` and `aRepository`, their only implementations): type your code against them. A collection holds `Entity` objects (`@template T of Entity`), never an `EmbeddedEntity` — so `ignore()`, `setCollection()`... are there on what comes out of one. Every repository answers `getDistinct()` (a generic version in `aRepository`; MongoDB's is native). `iSchema` stays minimal on purpose: `aBoolSchema` (the `true`/`false` schemas) implements it too, and has none of an object schema's methods. What `Collection::getSchema()` returns is a full `Schema` (its docblock says so); where a sub-schema could be a boolean one, check `instanceof Schema` before calling `getVisibleProperties()` & co. A property typed with an interface leads to one: an entity's interface (`iClient`, any `iSchemaProvider`) resolves to `true`, since its value may be of any class. So `getSchemaFor('owner/username')` is null when `owner` is typed with one, as is anything `walkSchemaFor()` meets past a boolean schema; a filter then compares the value as it is, uncoerced (a query string's `'30'` still compares as a number). `getSchemaFor('metadata/any')` is the `true` schema itself: an entity's `metadata` takes any key. ## Only a `Signal` carries a return value `setReturn()`/`getReturn()`/`hasReturn()` exist on `Signal`, not on `Event`: an event whose listeners answer must extend `Signal`. See [schemas-and-events.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fschemas-and-events.md#event-system). ## `HtmlPrinter` does not escape strings `HtmlPrinter::exportString()` returns a string unchanged unless it is dumping (debug output). That is how an `HtmlDocument` embeds markup, and several call sites rely on it (`HtmlDocument` bodies, `ImportSocket`), so it cannot simply start escaping everything. The consequence: **any user-supplied string printed into an HTML document is an XSS** unless it is escaped: - in a template, `htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE)` what you print (the demo's templates do, through a one-line `$e` helper); - through the printer, `new PrintOptions(escape: true)`: `$this->renderValue($value, new PrintOptions(escape: true))`, `HtmlPrinter::Print($value, new PrintOptions(escape: true))` (strings nested in arrays too). The printer renders a value its own way: a number as `<span class="number">36</span>`, null as `NULL`; - in a tree of nodes, as an `XmlText`: `new XmlNode('td', [], [new XmlText($name)])`. A string a node holds is markup, an `XmlText` is text: escaped once when it is printed, `escape` or not; - for a whole document, `new HtmlDocument(null, new DocumentOptions(print: new PrintOptions(escape: true)))` escapes every value it renders through its printer. The page's own markup is kept (it is made of `XmlNode`s), but a string an `XmlNode` holds is escaped too: build markup as nodes (`new XmlNode('b', [], ['text'])`), not as `'<b>text</b>'`. What a `<script>` or a `<style>` holds is left as it is: it is not HTML (the browser decodes no entity there), so the source `addInlineScript()` adds stays valid. `['escape' => true]` works too, but a typo there (`['escpae' => true]`) is silently ignored and prints the string raw; `new PrintOptions(escpae: true)` is an error. What is always text is escaped whatever `escape` says: the title `HtmlDocument::setTitle()` sets (an `XmlText`; `getTitle()` gives it back as it was set), a resource's uri, a validation error's message (it can quote the input's keys), a log's message and backtrace (an `HtmlDocument`'s SERVER CONSOLE, `HtmlPrinter::Print($exception)`), and in a dump (`\MMO\dump()` in a web request) the strings, keys and property names of a value. Log an untrusted value as it is (`\MMO\warning($this, 'unknown key [{key}].', ['key' => $key])`): escaping it first would print `&lt;` in the page. (Until 2026-09 all but a dump's strings were printed as markup: a title holding `</title><script>`, a dumped request body keyed `<img src=x onerror=...>`, or a log quoting such a key, put live markup in the page; and a document built with `escape: true` broke its inline scripts, `&&` coming out as `&amp;&amp;`.) ## A variable set at the top of an included file is not always global `$SHELL_BUFFERING = 0;` at the top of `functions/shell.php` sets a global only when the file is included from the top level of a script. Included from inside a function — and phpunit includes its bootstrap from one — it sets a local of that function, and every `global $SHELL_BUFFERING` below reads null. Set it as `$GLOBALS['NAME'] = ...`. Confirmed: until 2026-09, `restoreShellBuffering()` warned "Decrement on type null" in the test suite only. ## Asking without a terminal `\MMO\core\shell\read()`, and so `ask()` and `confirm()`, read a whole line when the input is not a terminal (a pipe, a file, a stream given to `setInput()`), and return null at the end of the input. Until 2026-09 they read keys even then: a piped `y` made `confirm()` answer false (and swallowed the next line), an empty input made it answer true — its default — and every prompt printed an `stty` error. Code that ran `echo y | php script.php` to confirm something was not confirming anything. ## Asking at a terminal At a terminal a question only takes what is typed once its prompt is on screen (up to its ` > `): `ask()`, `confirm()`, `prompt()`, `promptList()`, `choose()` and `readSecret()` drop what was typed before, a line not ended included. `read()`, and so `ask()` and `confirm()`, take keys as they are pressed: `y` answers `confirm()` without ENTER, and the keys left once an answer is complete are dropped too. Until 2026-09 the ENTER typed after a `y` stayed queued and answered the next question — `confirm()`, `prompt()` and `choose()` with their default (mmo.admin's next target's plan, the pause after its canary), `readSecret()` with an empty secret — and keys that arrived together (a paste, keys sent in one go over ssh) were read three bytes at a time as one key: `y` and ENTER typed in one go were neither, and the question waited for good. A test typing into a pseudo-terminal waits for the ` > ` first, or its keys are dropped (core's `tests/inc/terminal.php`). ## `jsonDecode()` comments `\MMO\core\strings\jsonDecode()` (and `\MMO\core\files\json()`, whose `$cleanUp` defaults to true) strips `//` and `/* */` comments and trailing commas outside strings. Until 2026-09 its patterns did not know about strings: a string starting with `//` — a protocol-relative url, the `"//why": "..."` comment keys mmo.admin's `Store` documents — was cut short, and the file failed to decode; a comment after a number, `true` or a `{` was not recognised, and a `/* */` one never. ## A JsonRepository's uids, removals and ids Fixed 2026-09 (`tests/modules/TestJsonRepository.php`); mmo.edms 3.0's tests had worked around all three: - an entity's uid was `file://users.json#2` whatever its folder, and every repository answers `\MMO\fetch()`: with two JsonRepositories holding a `users.json` (two applications in one process, every test class), fetching one's user found the other's, and a file's references could be read as the other repository's entities. The uid now names the folder with a tag (`file://3f2a9c1b/users.json#2`): the same in every request, another one once the folder moved. - the files did not change: they name an entity by its file's name alone (`<file://users.json#2>`), and the repository reading them answers first. Where nothing says which repository reads -- a uid kept from before (`data.json?uid=file://users.json%232`), a reference into another JsonRepository -- the first one holding a file of that name answers: give different names to the files of two repositories when one's entities reference the other's. - an entity removed stayed in its collection for the rest of the request (`getById()` found it). It leaves it once the commit saved it. - an id was one more than the highest held, so the entity removed last gave its id to the next one, and what was keyed by it (a scope `edms.files.12`, a link, a cache) applied to that one. An id is now never given twice: the file keeps the highest id it gave when its entities no longer tell it (`"$lastId": 12`). Leave that key in a file edited by hand; a core of before 2026-09 cannot load a file holding it (a TypeError), should one be rolled back. mmo.pdo's and mmo.mongodb's repositories are separate code, and had the removal bug too: worse, an entity created and removed before being saved was inserted by their next commit. Both fixed 2026-09 (their `tests/repository`). Their ids: mmo.mongodb's are ObjectIds, never given twice. mmo.pdo's `AUTOINCREMENT` key no longer gives one twice on SQLite, where it was created as the rowid, which does (a table created before keeps doing it: its `docs/Troubleshooting.md`). The ids mmo.pdo's `generateId()` gives, for a key the database does not generate, stay above the ids its repository removed, but a later request only sees the rows (its `docs/Repository.md`, Ids). ## An exception's code is not always an int `$e->getCode()` returns whatever the exception holds: `Throwable::getCode()` declares no return type, and `Exception::$code` is untyped. A `PDOException`'s code is its SQLSTATE, a string: `'HY000'` (SQLite's, for every error), `'42S02'`, `'23000'`. Handed to an `int` parameter, it is a `TypeError`, unless it is all digits and the file is not strict (`'23000'` then silently becomes 23000). So: - to wrap one, pass it as the previous exception, not its code: `new eRuntime($this, '...', [...], $e)`. `new eRuntime($this, '...', [], $e, $e->getCode())` is itself a `TypeError`; - to tell driver errors apart, compare the SQLSTATE as a string (`$e->getCode() === '23000'`), or read the driver's own code, an int, from `$e->errorInfo[1]`; - to turn any code into a status, use `\MMO\core\server\httpStatus($e->getCode())`: an int that is an HTTP status is kept, anything else is 500 (a string too, even `'404'`). Fixed 2026-09 (`tests/modules/TestErrorResponse.php`): `httpStatus()` took an `int`, so the error handler itself threw a `TypeError` on a `PDOException`. An uncaught one ended in a PHP fatal (exit 255) instead of the 500 page, and one thrown by a route made `Application::handle()` throw instead of answering; mmo.client's `Theme` error pages had the same call. mmo.pdo wraps the exception when a statement runs (`Statement::execute()`, and so `fetchAll()`): you get an `ePDO`, code 0, with the `PDOException` as its previous. `aHandler::prepare()` and `Statement::prepare()` called directly, and the raw handle (`getHandle()`), throw the `PDOException` itself. ## Testing discipline that actually catches these Every bug above was found by **writing a small real script and running it** — `require 'bootstrap.php';` then exercise the class directly — never by reading code and reasoning about what it "should" do. Several of these bugs read as completely correct on a normal review pass; the precedence and shadowing ones in particular are easy to miss without execution. When something in this framework seems off, the fastest path to ground truth is a five-line reproduction script, not more reading.