PHP-FPM / php -S
Default. php -S localhost:8080 index.php, or point PHP-FPM at that front controller.
alencarfreire/stem · v0.3.0 · PHP 8.3+
StemPHP is a Roda-style routing tree: the route closure runs on every request and consumes the URI segment by segment. No route table, no regex dump, no middleware stack. Copy the example, hit it with curl.
composer require alencarfreire/stem:^0.3
PHP 8.3+. Zero runtime dependencies. FrankenPHP, RoadRunner and Swoole are optional.
Create index.php next to vendor/. This is a small users API: list, create, show, nested posts, delete, JSON 404.
<?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, 'name' => 'StemPHP']));
$r->on('users', function () use ($r): void {
$r->get(function () use ($r): void {
$page = (int) $r->queryParam('page', '1');
$r->json([
'page' => $page,
'data' => [
['id' => 1, 'name' => 'Ada'],
['id' => 2, 'name' => 'Linus'],
],
]);
});
$r->post(function () use ($r): void {
$body = $r->jsonBody() ?? [];
$name = $body['name'] ?? '';
if (!is_string($name) || $name === '') {
$r->json(['error' => 'name required'], 422);
return;
}
$r->json(['id' => 3, 'name' => $name], 201);
});
$r->onInt(function (int $id) use ($r): void {
$r->on('posts', function () use ($r, $id): void {
$r->get(fn () => $r->json([
'user_id' => $id,
'posts' => [['id' => 10, 'title' => 'Hello']],
]));
});
$r->get(fn () => $r->json(['id' => $id, 'name' => 'Ada']));
$r->delete(fn () => $r->noContent());
});
});
});
$app->run();
Start the built-in server with that file as the router (otherwise /users is looked up as a file on disk):
php -S localhost:8080 index.php
Same process, real HTTP. Status codes in comments are what Stem returns.
Health
curl -s localhost:8080/
{ "ok": true, "name": "StemPHP" }
List with query string
curl -s 'localhost:8080/users?page=2'
{
"page": 2,
"data": [
{ "id": 1, "name": "Ada" },
{ "id": 2, "name": "Linus" }
]
}
Create
curl -s -X POST localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"Grace"}'
{ "id": 3, "name": "Grace" }
Show, nested collection, delete
curl -s localhost:8080/users/1
# 200 {"id":1,"name":"Ada"}
curl -s localhost:8080/users/1/posts
# 200 {"user_id":1,"posts":[{"id":10,"title":"Hello"}]}
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/users/1
# 204
curl -s localhost:8080/nope
# 404 {"error":"not_found"}
curl -s -o /dev/null -w '%{http_code}\n' -X PUT localhost:8080/users
# 405 Allow: GET, HEAD, POST
Stem does not include an ORM. The indicated app layout is PDO + repository + route files. Connection at boot, SQL out of HTTP, load the row once on the branch. Full runnable copy: examples/app/ in the repo.
examples/app/
index.php # php -S router
worker.php # FrankenPHP: AppFactory::make() outside the loop
AppFactory.php # PDO + App + $r->run(...)
Database.php # sqlite file, or any DSN
schema.sql
Repository/UserRepository.php
routes/users.php # Closure(Request): void
var/app.sqlite # created at runtime
From a clone of this repository:
php -S localhost:8080 examples/app/index.php
curl -s localhost:8080/users
curl -s -X POST localhost:8080/users \
-H 'Content-Type: application/json' -d '{"name":"Grace"}'
In your own project, copy that folder, point vendor/autoload.php at your root, keep AppFactory::make() for FPM and for the worker.
Boot — PDO once
$pdo ??= Database::connect();
$users = new UserRepository($pdo);
$makeUsers = require __DIR__ . '/routes/users.php';
$app->route(function (Request $r) use ($makeUsers, $users): void {
$r->run($makeUsers($users));
});
Repository — no Request
public function find(int $id): ?array
{
$st = $this->pdo->prepare('SELECT id, name FROM users WHERE id = :id');
$st->execute(['id' => $id]);
$row = $st->fetch();
return $row === false ? null : /* map */;
}
Route — load once on the branch
$r->onInt(function (int $id) use ($r, $users): void {
$user = $users->find($id);
if ($user === null) {
$r->json(['error' => 'not_found'], 404);
return;
}
$r->get(fn () => $r->json($user));
$r->delete(function () use ($r, $users, $id): void {
$users->delete($id);
$r->noContent();
});
});
Worker: AppFactory::make() (and the PDO connection) stay outside frankenphp_handle_request. See examples/app/worker.php. Swap SQLite with Database::connect('mysql:host=127.0.0.1;dbname=app') — the repository SQL is standard.
jsonBody() decodes the raw body. Invalid JSON is halt(400). Empty body is null.
$r->on('users', function () use ($r): void {
$r->post(function () use ($r): void {
$body = $r->jsonBody() ?? [];
$name = $body['name'] ?? '';
if (!is_string($name) || $name === '') {
$r->json(['error' => 'name required'], 422);
return;
}
$r->json(['id' => 3, 'name' => $name], 201);
});
});
Load the user (or reject) once on the branch, then every nested verb sees the same check.
$r->on('admin', function () use ($r): void {
if ($r->bearerToken() !== 'secret') {
$r->json(['error' => 'unauthorized'], 401);
return;
}
$r->get(fn () => $r->json(['ok' => true]));
$r->post(fn () => $r->json(['queued' => true], 202));
});
curl -s localhost:8080/admin
# 401 {"error":"unauthorized"}
curl -s localhost:8080/admin \
-H 'Authorization: Bearer secret'
{ "ok": true }
Put on('posts') before the leaf get(). onInt is a prefix (still matches /users/1/posts). Use isInt when the id must be the last segment.
$r->on('users', function () use ($r): void {
$r->onInt(function (int $id) use ($r): void {
$r->on('posts', function () use ($r, $id): void {
$r->get(fn () => $r->json(['user_id' => $id, 'posts' => []]));
});
$r->get(fn () => $r->json(['id' => $id]));
$r->delete(fn () => $r->noContent());
});
});
GET /users/1 → show. GET /users/1/posts → nested. GET /users/1/nope → 404. PUT /users/1 → 405 + Allow: DELETE, GET, HEAD.
run() mounts another function (Request $r) on the current remaining path. A miss does not seal, so the next run() can still match.
// routes/users.php
return function (Request $r): void {
$r->on('users', function () use ($r): void {
$r->get(fn () => $r->json([['id' => 1]]));
});
};
// index.php
$app->route(function (Request $r): void {
$r->run(require __DIR__ . '/routes/users.php');
$r->run(require __DIR__ . '/routes/posts.php');
});
$app->notFound(fn (Request $r) => $r->json([
'error' => 'not_found',
'path' => $r->path(),
], 404));
$app->error(function (Throwable $e, Request $r): void {
$r->json(['error' => 'server_error', 'detail' => $e->getMessage()], 500);
});
Without these, a total miss is plain 404 Not Found. Uncaught throwables bubble to the SAPI. HaltException from halt() is never sent to error().
No need to boot a server. Request::create + App::handle.
$response = $app->handle(Request::create(
'POST',
'/users',
['Content-Type' => 'application/json'],
[],
'{"name":"Grace"}',
));
assert($response->status() === 201);
assert($response->body() === '{"id":3,"name":"Grace"}');
The route closure runs on every request. Each matcher is an if that may consume the next segment. There is no compiled trie.
Allow. No backtracking.| 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 (terminal). |
$r->get('about', $cb) | Method + exact remaining /about. |
$r->root($cb) | Remaining is / or empty. Any method. |
$r->onInt($cb) | Prefix integer. Still matches /users/1/posts. |
$r->isInt($cb) | Exact remaining integer. Does not match /users/1/posts. |
$r->onParam($cb) / isParam($cb) | String segment, prefix or exact. |
$r->run($branch) | Mount function (Request $r). Does not seal on miss. |
$r->branches(['users' => $cb]) | O(1) lookup of the next segment (Roda hash_branches). Hit consumes the key, then seals. Remaining path inside the callback is after the key. Miss leaves later matchers free to run. |
$r->json($data, $status = 200) | JSON body. Does not throw. |
$r->html($html, $status = 200) | HTML body. Does not throw. |
$r->halt($status, $body, $headers) | Early exit (never). |
$r->redirect($url, $status = 302) | Location. |
$r->noContent() | HTTP 204. |
$r->cookie($name, $value, $options = []) | Set-Cookie. |
Not Roda: bare get() / post() are terminal (method + empty remaining). Nest on() branches above the leaf get().
$rMatchers do not inject Request. Capture $r with use ($r) or arrows. Only onInt / isInt / onParam / isParam pass a capture. run() is the exception: it receives function (Request $r), same as App::route().
$r->on('users', function () use ($r): void {
$r->isInt(function (int $id) use ($r): void {
$r->get(fn () => $r->json(['id' => $id]));
});
});
Same tree as the original mini example. Click a request and watch consume / miss / leaf.
$r is the HTTP message and the routing context.
| Method | Role |
|---|---|
Request::fromGlobals() | FPM / worker. Call inside the per-request loop. |
Request::create($method, $path, $headers, $query, $body, $post) | Tests and adapters. |
method() / path() / remaining() | Verb, normalized path, unconsumed suffix. |
header($name) / headers() | Lazy scan of $_SERVER. |
query() / queryParam($key, $default) | Query string. |
form() / formParam($key, $default) | $_POST from fromGlobals(). |
rawBody() / jsonBody() | Raw body. Invalid JSON → halt(400). |
bearerToken() / wantsJson() | Authorization: Bearer and Accept. |
json / html / redirect / noContent write here. HEAD matches get(); send() omits the body.
$r->json(['ok' => true], 200);
$r->html('<h1>Hi</h1>');
$r->redirect('/users', 302);
$r->noContent();
$r->cookie('sid', 'abc', ['path' => '/', 'httponly' => true, 'samesite' => 'Lax']);
$app->route(function (Request $r): void { /* once */ });
$response = $app->handle($request); // per request
$app->run(); // fromGlobals + handle + send
route() once. A second call throws LogicException.handle() catches HaltException. Other throwables go to error() if set.json / html) does not throw.Default. php -S localhost:8080 index.php, or point PHP-FPM at that front controller.
Boot $app once. fromGlobals() stays inside the loop.
Map PSR-7 → Request::create → handle() → status/headers/body. See examples/roadrunner/.
Same handle() contract. Example: examples/swoole/server.php.
use Stem\Integrations\FrankenPhpWorker;
FrankenPhpWorker::run(
$app,
maxRequests: (int) ($_SERVER['MAX_REQUESTS'] ?? 0),
collectEvery: 0,
);
Reuse App. Never reuse Request. No static request state. fromGlobals() belongs in the handler, not at worker boot.
Routing is O(path depth × siblings at that level), not O(total routes). At a busy root, branches() makes that level O(1) (hash lookup), like Roda hash_branches. No regex, no Reflection, no exception on json().
$r->root(fn () => $r->json(['ok' => true]));
$r->branches([
'users' => $makeUsers($users),
'customers' => $makeCustomers($customers),
]);
Inside $makeUsers the remaining path is already past users — do not wrap it in another on('users'). That is the difference from run(), which does not consume a key.
Same API (PDO + SQLite WAL, 10-column customers), same image (dunglas/frankenphp, PHP 8.5, JIT, 4 workers), same load: wrk -t4 -c20 -d10s. Docker Desktop on macOS. Green cells are best in that row. These are measured, not invented — reproduce with the harness, do not copy them as gospel.
Read — GET /customers
| Metric | StemPHP | FlightPHP 3 | Slim 4 |
|---|---|---|---|
| Throughput | 7,282.90 req/s | 6,775.64 req/s | 6,250.74 req/s |
| Latency p50 | 1.88 ms | 2.16 ms | 2.40 ms |
| Latency p99 | 59.44 ms | 47.37 ms | 46.09 ms |
| RAM idle | 32.11 MB | 33.88 MB | 44.81 MB |
| RAM peak | 66.46 MB | 51.62 MB | 61.61 MB |
| CPU avg / peak | 276% / 281% | 318% / 322% | 348% / 354% |
Write — POST /customers
| Metric | StemPHP | FlightPHP 3 | Slim 4 |
|---|---|---|---|
| Throughput | 2,413.79 inserts/s | 2,221.66 inserts/s | 2,145.92 inserts/s |
| Latency p50 | 5.58 ms | 6.25 ms | 6.64 ms |
| Latency p99 | 376.55 ms | 238.74 ms | 305.72 ms |
| RAM peak | 89.65 MB | 85.33 MB | 63.16 MB |
| CPU avg / peak | 154% / 165% | 157% / 169% | 170% / 186% |
Stem is +7.5% vs Flight and +16.5% vs Slim on reads, with a lower p50 and less CPU — no PSR-7 objects, no middleware stack, no regex router. Slim wins write peak RAM; Flight wins read peak RAM. The p99 on inserts is SQLite WAL under 4 workers, not the matcher.
Same Stem app on php -S: 349 req/s read. FrankenPHP worker is ~21× that, because the route tree stays in memory.
Roda + Sequel (Puma, same Docker, same wrk) still leads raw throughput (19,807 req/s read) at about 2× Stem’s peak RAM (133 MB vs 66 MB). Stem’s pitch in PHP is the Flight/Slim table, not beating MRI + oj.
php -S 127.0.0.1:8080 index.php
wrk -t4 -c20 -d10s --latency http://127.0.0.1:8080/customers
| Class | Role |
|---|---|
Stem\App | route, handle, run, notFound, error. |
Stem\Request | HTTP + routing context. |
Stem\Response | Status, headers, body, cookies, send(). |
Stem\Router | Internal path cursor (@internal). |
Stem\Exceptions\HaltException | Only halt(). |
Stem\Integrations\FrankenPhpWorker | Optional worker loop. |