# StemPHP > Ultralight executable routing-tree micro-framework for PHP 8.3+. Packagist: alencarfreire/stem. Namespace: Stem\. Zero runtime dependencies. Current: v0.3.0. Human docs: https://alencarfreire.github.io/stem/ (EN) · https://alencarfreire.github.io/stem/pt/ (pt-BR) Source: https://github.com/alencarfreire/stem Maintainer agent guide (this repo): /AGENTS.md This file is for AI agents **using or extending** StemPHP. Prefer it over scraping HTML docs. ## Install ```bash composer require alencarfreire/stem:^0.3 ``` PHP >= 8.3. Optional: ext-frankenphp, ext-swoole, spiral/roadrunner-http (examples only). ## Canonical app Save as `index.php` next to `vendor/`, then `php -S localhost:8080 index.php`. ```php route(function (Request $r): void { $r->root(fn () => $r->json(['message' => 'StemPHP API'])); $r->on('users', function () use ($r): void { $r->get(fn () => $r->json([['id' => 1, 'name' => 'John']])); $r->post(fn () => $r->json(['id' => 2], 201)); $r->onInt(function (int $id) use ($r): void { $r->get(fn () => $r->json(['id' => $id, 'name' => 'John'])); $r->delete(fn () => $r->halt(204)); }); }); }); $app->run(); // FPM / php -S ``` ## Mental model The route closure **executes every request** (Roda). Matchers consume remaining path segments. A hit sets `$done`; sibling matchers no-op. No route compilation. - Total miss → HTTP 404 `Not Found` - Branch taken, leftover path / no leaf → HTTP 404 - Branch taken, wrong method → HTTP 405 + `Allow` - `OPTIONS` with visited verbs and no `options()` leaf → HTTP 204 + `Allow` - `HEAD` matches `get()`; `send()` omits body - `get()`/`post()`/`put()`/`delete()`/`patch()` without a segment are **terminal** (method AND remaining empty). This differs from Roda `r.get`. ## Callbacks Do **not** type-hint `Request` on matcher callbacks. Capture `$r` from `route(function (Request $r)` via `use ($r)` or arrow functions. Only `onInt` passes `int`; `onParam` passes `string`. Wrong: `$r->on('users', function (Request $r) { ... })` → ArgumentCountError. Right: `$r->on('users', function () use ($r) { ... })` ## Matchers | Call | When it runs | |---|---| | `$r->on('users', $cb)` | remaining starts with `users` (prefix) | | `$r->is('users', $cb)` | remaining is exactly `/users` | | `$r->is($cb)` | remaining empty | | `$r->root($cb)` | remaining empty, any HTTP method | | `$r->get($cb)` (and post/put/delete/patch) | that method and remaining empty | | `$r->get('about', $cb)` | GET and remaining exactly `/about` | | `$r->onInt($cb)` | next segment integer (`0`,`42`; not `01`,`-1`, overflow) | | `$r->isInt($cb)` | exact remaining integer (not `/users/1/posts`) | | `$r->onParam($cb)` | next non-empty segment as string | | `$r->isParam($cb)` | exact remaining one segment | | `$r->run($branch)` | `callable(Request): void` on current path; does not seal | | `$r->branches(['users' => $cb, ...])` | O(1) next-segment lookup. Hit consumes the key then seals. Miss is a no-op. Callback remaining path is *after* the key. | | `$r->json($data, $status=200)` | write JSON, set done, no throw | | `$r->html($html, $status=200)` | write HTML, set done, no throw | | `$r->halt($status, $body='', $headers=[])` | write + throw HaltException (`never`) | | `$r->redirect($url, $status=302)` | Location | | `$r->noContent()` | 204 | | `$r->cookie($name, $value, $opts=[])` | Set-Cookie | | `$r->options($cb)` | OPTIONS leaf; automatic Allow if omitted | Path: trailing slash stripped except `/`; `//` collapsed; `?`/`#` dropped; decode per segment. ## Kernel ```php $app->route(Closure): self // once; second call LogicException $app->notFound(fn (Request): void): self $app->error(fn (Throwable, Request): void): self $app->handle(Request): Response $app->run(): void // fromGlobals + handle + send (HEAD omits body) ``` `handle()` catches `HaltException`. Other throwables go to `error()` if set. ## Request / Response (public) ``` Request::fromGlobals(): self // inside worker loop, never at boot Request::create(string $method, string $path, array $headers=[], array $query=[], string $body='', array $post=[]): self method(): string path(): string // normalized, offset 0 remaining(): string // unconsumed header(string $name): ?string // lazy $_SERVER headers(): array query(): array queryParam(string $key, mixed $default=null): mixed form(): array // $_POST formParam(string $key, mixed $default=null): mixed rawBody(): string // GET/HEAD skipped jsonBody(): mixed // halt(400) if invalid bearerToken(): ?string wantsJson(): bool response(): Response // lazy isDone(): bool isWritten(): bool allowed(): list Response::status(): int headers(): array body(): string cookies(): list withStatus / withHeader / withBody / withCookie writeJson / writeHtml send(bool $sendBody=true): void // idempotent; 204/304 omit body ``` ## Indicated app architecture Do not add an ORM to Stem. Copy `examples/app/`: PDO at boot (`AppFactory::make()`), `UserRepository` (SQL only), `routes/users.php` mounted with `$r->run()`. Load the row once inside `onInt`. Worker: `make()` outside the loop. SQLite by default; pass a MySQL/Postgres DSN to `Database::connect()`. ## Performance (measured, not invented) Same PDO+SQLite `customers` API, `dunglas/frankenphp` 4 workers + JIT, `wrk -t4 -c20 -d10s`, Docker Desktop macOS (2026-09-09): GET /customers: Stem 7282.90 req/s p50 1.88 ms idle RAM 32.11 MB CPU 276% | Flight 6775.64 p50 2.16 | Slim 6250.74 p50 2.40 idle 44.81 MB. POST /customers: Stem 2413.79 inserts/s p50 5.58 ms | Flight 2221.66 | Slim 2145.92. Slim has the lowest write peak RAM (63 MB vs Stem 90 MB). Stem php -S: 349 req/s read (~21× less than the worker). Roda+Sequel Puma Docker (reference, not PHP): 19807 req/s read, 133 MB peak. Do not quote other numbers. Reproduce; do not invent. ## Isolation (workers) Reuse `App`. Never reuse `Request`. No static request fields. FrankenPHP: ```php use Stem\Integrations\FrankenPhpWorker; FrankenPhpWorker::run($app, maxRequests: 0, collectEvery: 0); ``` `$app` outside the loop. `Request::fromGlobals()` inside. RoadRunner/Swoole: map to `Request::create`, then `handle()`, write status/headers/body. See `examples/`. ## Classes - `Stem\App` - `Stem\Request` - `Stem\Response` - `Stem\Router` (@internal path cursor) - `Stem\Exceptions\HaltException` - `Stem\Integrations\FrankenPhpWorker` ## Do not - Add core Composer deps, PSR-7, middleware, sessions, views, DI. - Use `ReflectionFunction` to support mixed callback arities. - Throw on `json()`/`html()`. - Call `fromGlobals()` at worker bootstrap. - Enable `gc_collect_cycles` per request by default. - Assume `get($cb)` matches leftover path (it does not).