alencarfreire / stem
Ultralight executable routing-tree micro-framework for PHP 8.3+. Runs on FPM, FrankenPHP, RoadRunner and Swoole.
Requires
- php: >=8.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
Suggests
- ext-frankenphp: Optional: FrankenPHP worker mode (in-memory request loop)
- ext-swoole: Optional: Swoole HTTP server using App::handle()
- spiral/roadrunner-http: Optional: RoadRunner HTTP worker using App::handle()
Provides
None
Conflicts
None
Replaces
None
README
Ultralight executable routing-tree micro-framework for PHP 8.3+. Inspired by Roda (Ruby). Zero runtime dependencies.
Docs: English · Português (Brasil) · llms.txt
Indicated app layout (PDO, not an ORM): examples/app/ — Architecture.
Runs on PHP-FPM, php -S, Apache, FrankenPHP (with or without worker mode), RoadRunner, and Swoole. FrankenPHP is an optional fast path, not a requirement.
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Stem\App; use Stem\Request; $app = new App(); $app->notFound(fn (Request $r) => $r->json(['error' => 'not_found'], 404)); $app->route(function (Request $r): void { $r->root(fn () => $r->json(['ok' => true])); $r->on('users', function () use ($r): void { $r->get(fn () => $r->json([['id' => 1, 'name' => 'Ada']])); $r->post(function () use ($r): void { $name = ($r->jsonBody() ?? [])['name'] ?? ''; if ($name === '') { $r->json(['error' => 'name required'], 422); return; } $r->json(['id' => 2, 'name' => $name], 201); }); $r->onInt(function (int $id) use ($r): void { $r->get(fn () => $r->json(['id' => $id, 'name' => 'Ada'])); $r->delete(fn () => $r->noContent()); }); }); }); $app->run();
composer require alencarfreire/stem:^0.3 php -S localhost:8080 index.php curl -s localhost:8080/users curl -s -X POST localhost:8080/users -H 'Content-Type: application/json' -d '{"name":"Grace"}' curl -s localhost:8080/users/1
Routing tree
The route closure runs on every request. Matchers consume the remaining path. A match finishes that level; siblings do not run. Total miss → 404. Branch taken, leftover path → 404. Wrong method → 405 + Allow.
| Call | Meaning |
|---|---|
$r->on('users', $cb) |
Prefix. Consumes users, enters the branch. |
$r->is('users', $cb) |
Exact remaining path /users. |
$r->is($cb) |
Remaining path is empty. |
$r->get($cb) / post / put / delete / patch |
HTTP method and remaining path empty. |
$r->get('users', $cb) |
Method + exact remaining /users. |
$r->root($cb) |
Remaining path is / or empty (any method). |
$r->onInt($cb) |
Prefix: next segment is an integer (0, 42; not 01, -1). |
$r->isInt($cb) |
Exact remaining integer (not /users/1/posts). |
$r->onParam($cb) |
Prefix: next non-empty segment, as string. |
$r->isParam($cb) |
Exact remaining one segment. |
$r->run($branch) |
Run function (Request $r) on the current path; does not seal on miss. |
$r->branches(['users' => $cb]) |
O(1) lookup of the next segment (Roda hash_branches). |
$r->json($data, $status = 200) |
JSON body + Content-Type. Sets $done. Does not throw. |
$r->html($html, $status = 200) |
HTML body. Sets $done. Does not throw. |
$r->halt($status, $body, $headers) |
Early exit (never). Throws an internal HaltException. |
$r->redirect($url, $status = 302) |
Location. |
$r->noContent() |
HTTP 204. |
$r->jsonBody() / queryParam / formParam / bearerToken |
Request helpers. Invalid JSON → halt(400). |
Matchers do not inject Request as an argument (no Reflection on the hot path). Use use ($r) or arrow functions, which already capture $r from the route() closure. Captures (onInt / onParam) are the only extra arguments.
Request isolation
App stores only the route closure. Each handle() gets a new Request; Response is created on first write. No static request state. Safe for FrankenPHP worker mode, RoadRunner, and Swoole.
$response = $app->handle(Request::create('GET', '/users/1'));
Request::fromGlobals() must be called inside the per-request handler, never at worker boot.
Deploy
PHP-FPM / php -S (default)
Save the app as index.php next to vendor/ and serve it as the front controller (php -S localhost:8080 index.php, or point PHP-FPM/Apache at that file).
FrankenPHP worker mode (optional)
use Stem\Integrations\FrankenPhpWorker; FrankenPhpWorker::run($app, maxRequests: (int) ($_SERVER['MAX_REQUESTS'] ?? 0)); // collectEvery: 0 (default) skips gc_collect_cycles(); set e.g. 128 if a leaky extension forces it.
$app is created outside the loop. See examples/frankenphp/.
RoadRunner / Swoole
App::handle() is the runtime contract. Convert the incoming request to Request::create(...), then write $response->status(), headers(), and body(). See examples/roadrunner/ and examples/swoole/. Those packages are not Composer requirements.
Performance
StemPHP matches Roda's model: routing cost is O(path depth × siblings at that level), not O(total routes). It is not a compiled matcher (FastRoute / Symfony Routing). The hot path has no regex, no Reflection, and no exception on json() / html().
Per-request work that is not on the matching path is deferred or skipped:
- Path parse uses
strpos/explode, notparse_url.rawurldecoderuns only if the URI contains%. GET/HEADdo not readphp://input.$_SERVERheaders are scanned only if$r->header()/headers()is called.Responseis allocated on first write (json/html/halt/response()), not on every request.onIntusesctype_digit+(int)(nofilter_var).- Worker
gc_collect_cycles()is off unless you passcollectEvery.
Measured on FrankenPHP worker (4 workers, JIT, SQLite WAL, wrk -t4 -c20 -d10s, Docker): Stem 7,283 req/s read vs Flight 6,776 vs Slim 6,251, lower p50 and less CPU. Tables and methodology: docs #performance. Do not invent numbers; measure your own index.php.
php -S 127.0.0.1:8080 index.php wrk -t4 -c20 -d10s --latency http://127.0.0.1:8080/customers
Development
composer install
composer test
composer analyze
composer format
Requires PHP 8.3+. CI runs 8.3 and 8.4.
AI agents
- Maintain this repo:
AGENTS.md - Consume the API from another project:
docs/llms.txt
License
MIT