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