PHP-FPM / php -S
Padrão. php -S localhost:8080 index.php, ou aponte o PHP-FPM para esse front controller.
alencarfreire/stem · v0.3.0 · PHP 8.3+
StemPHP é uma árvore de rotas no estilo Roda: o closure roda a cada request e consome a URI segmento a segmento. Sem tabela de rotas, sem dump de regex, sem stack de middleware. Copia o exemplo e bate com curl.
composer require alencarfreire/stem:^0.3
PHP 8.3+. Zero dependências de runtime. FrankenPHP, RoadRunner e Swoole são opcionais.
Crie index.php ao lado de vendor/. É uma API de users: listar, criar, mostrar, posts aninhados, apagar, 404 em JSON.
<?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();
Suba o servidor embutido usando esse arquivo como router (senão /users é procurado como arquivo no disco):
php -S localhost:8080 index.php
O mesmo processo, HTTP de verdade. Os status nos comentários são o que o Stem devolve.
Health
curl -s localhost:8080/
{ "ok": true, "name": "StemPHP" }
Lista com query string
curl -s 'localhost:8080/users?page=2'
{
"page": 2,
"data": [
{ "id": 1, "name": "Ada" },
{ "id": 2, "name": "Linus" }
]
}
Criar
curl -s -X POST localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"Grace"}'
{ "id": 3, "name": "Grace" }
Show, coleção aninhada, 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
O Stem não traz ORM. O layout indicado é PDO + repositório + arquivos de rota. Conexão no boot, SQL fora do HTTP, carrega a linha uma vez no ramo. Cópia rodável: examples/app/ no repositório.
examples/app/
index.php # router do php -S
worker.php # FrankenPHP: AppFactory::make() fora do loop
AppFactory.php # PDO + App + $r->run(...)
Database.php # sqlite em arquivo, ou qualquer DSN
schema.sql
Repository/UserRepository.php
routes/users.php # Closure(Request): void
var/app.sqlite # criado em runtime
A partir de um clone deste repositório:
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"}'
No seu projeto, copie a pasta, aponte o vendor/autoload.php para a raiz e use o mesmo AppFactory::make() no FPM e no worker.
Boot — PDO uma vez
$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));
});
Repositório — sem 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 */;
}
Rota — carrega uma vez no ramo
$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() (e o PDO) ficam fora de frankenphp_handle_request. Veja examples/app/worker.php. Troque o SQLite por Database::connect('mysql:host=127.0.0.1;dbname=app') — o SQL do repositório é padrão.
jsonBody() decodifica o body. JSON inválido vira halt(400). Body vazio é 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);
});
});
Valida uma vez no ramo; todos os verbos debaixo herdam o mesmo 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 }
Coloque on('posts') antes da folha get(). onInt é prefixo (ainda casa /users/1/posts). Use isInt quando o id tem que ser o último segmento.
$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 → aninhado. GET /users/1/nope → 404. PUT /users/1 → 405 + Allow: DELETE, GET, HEAD.
run() monta outro function (Request $r) no path restante. Miss não sela, então o próximo run() ainda pode casar.
// 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);
});
Sem isso, miss total é 404 Not Found em texto. Throwables sobem ao SAPI. HaltException do halt() não vai para o error().
Não precisa subir servidor. 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"}');
O closure de rotas roda a cada request. Cada matcher é um if que pode consumir o próximo segmento. Não há trie compilada.
Allow. Sem backtracking.| Chamada | Significado |
|---|---|
$r->on('users', $cb) | Prefixo. Consome users e entra no ramo. |
$r->is('users', $cb) | Path restante exato /users. |
$r->is($cb) | Path restante vazio. |
$r->get($cb) / post / put / delete / patch | Método HTTP e path restante vazio (terminal). |
$r->get('about', $cb) | Método + restante exato /about. |
$r->root($cb) | Restante é / ou vazio. Qualquer método. |
$r->onInt($cb) | Inteiro prefixo. Ainda casa /users/1/posts. |
$r->isInt($cb) | Inteiro restante exato. Não casa /users/1/posts. |
$r->onParam($cb) / isParam($cb) | Segmento string, prefixo ou exato. |
$r->run($branch) | Monta function (Request $r). Não sela no miss. |
$r->branches(['users' => $cb]) | Lookup O(1) do próximo segmento (Roda hash_branches). Hit consome a chave e sela. O path restante no callback é depois da chave. Miss deixa os matchers seguintes tentarem. |
$r->json($data, $status = 200) | Body JSON. Não lança. |
$r->html($html, $status = 200) | Body HTML. Não lança. |
$r->halt($status, $body, $headers) | Saída antecipada (never). |
$r->redirect($url, $status = 302) | Location. |
$r->noContent() | HTTP 204. |
$r->cookie($name, $value, $options = []) | Set-Cookie. |
Não é o Roda: get() / post() sem segmento são terminais (método + restante vazio). Aninhe on() acima da folha get().
$rMatchers não injetam Request. Capture $r com use ($r) ou arrows. Só onInt / isInt / onParam / isParam passam captura. run() é a exceção: recebe function (Request $r), igual ao App::route().
$r->on('users', function () use ($r): void {
$r->isInt(function (int $id) use ($r): void {
$r->get(fn () => $r->json(['id' => $id]));
});
});
A árvore do exemplo curto original. Clique numa request e veja consume / miss / folha.
$r é a mensagem HTTP e o contexto de roteamento.
| Método | Papel |
|---|---|
Request::fromGlobals() | FPM / worker. Chame dentro do loop por request. |
Request::create($method, $path, $headers, $query, $body, $post) | Testes e adapters. |
method() / path() / remaining() | Verbo, path normalizado, sufixo não consumido. |
header($name) / headers() | Scan lazy de $_SERVER. |
query() / queryParam($key, $default) | Query string. |
form() / formParam($key, $default) | $_POST via fromGlobals(). |
rawBody() / jsonBody() | Body cru. JSON inválido → halt(400). |
bearerToken() / wantsJson() | Authorization: Bearer e Accept. |
json / html / redirect / noContent escrevem aqui. HEAD casa o get(); o send() omite o body.
$r->json(['ok' => true], 200);
$r->html('<h1>Oi</h1>');
$r->redirect('/users', 302);
$r->noContent();
$r->cookie('sid', 'abc', ['path' => '/', 'httponly' => true, 'samesite' => 'Lax']);
$app->route(function (Request $r): void { /* uma vez */ });
$response = $app->handle($request); // por request
$app->run(); // fromGlobals + handle + send
route() uma vez. A segunda chamada lança LogicException.handle() captura HaltException. Outros throwables vão para error() se existir.json / html) não lança.Padrão. php -S localhost:8080 index.php, ou aponte o PHP-FPM para esse front controller.
Suba o $app uma vez. O fromGlobals() fica dentro do loop.
PSR-7 → Request::create → handle() → status/headers/body. Veja examples/roadrunner/.
O mesmo handle(). Exemplo: examples/swoole/server.php.
use Stem\Integrations\FrankenPhpWorker;
FrankenPhpWorker::run(
$app,
maxRequests: (int) ($_SERVER['MAX_REQUESTS'] ?? 0),
collectEvery: 0,
);
Reutilize o App. Nunca reutilize o Request. Sem estado estático de request. fromGlobals() fica no handler, não no boot do worker.
O roteamento é O(profundidade do path × irmãos naquele nível), não O(total de rotas). Numa raiz cheia, branches() deixa aquele nível O(1) (hash), como o hash_branches do Roda. Sem regex, sem Reflection, sem exception no json().
$r->root(fn () => $r->json(['ok' => true]));
$r->branches([
'users' => $makeUsers($users),
'customers' => $makeCustomers($customers),
]);
Dentro de $makeUsers o path restante já passou de users — não envolva de novo com on('users'). Essa é a diferença para o run(), que não consome chave.
A mesma API (PDO + SQLite WAL, customers com 10 colunas), a mesma imagem (dunglas/frankenphp, PHP 8.5, JIT, 4 workers), a mesma carga: wrk -t4 -c20 -d10s. Docker Desktop no macOS. Células verdes são o melhor da linha. Números medidos, não inventados — reproduza no harness, não trate como verdade universal.
Leitura — GET /customers
| Métrica | StemPHP | FlightPHP 3 | Slim 4 |
|---|---|---|---|
| Vazão | 7.282,90 req/s | 6.775,64 req/s | 6.250,74 req/s |
| Latência p50 | 1,88 ms | 2,16 ms | 2,40 ms |
| Latência p99 | 59,44 ms | 47,37 ms | 46,09 ms |
| RAM idle | 32,11 MB | 33,88 MB | 44,81 MB |
| RAM pico | 66,46 MB | 51,62 MB | 61,61 MB |
| CPU média / pico | 276% / 281% | 318% / 322% | 348% / 354% |
Escrita — POST /customers
| Métrica | StemPHP | FlightPHP 3 | Slim 4 |
|---|---|---|---|
| Vazão | 2.413,79 inserts/s | 2.221,66 inserts/s | 2.145,92 inserts/s |
| Latência p50 | 5,58 ms | 6,25 ms | 6,64 ms |
| Latência p99 | 376,55 ms | 238,74 ms | 305,72 ms |
| RAM pico | 89,65 MB | 85,33 MB | 63,16 MB |
| CPU média / pico | 154% / 165% | 157% / 169% | 170% / 186% |
Stem fica +7,5% vs Flight e +16,5% vs Slim na leitura, com p50 menor e menos CPU — sem objetos PSR-7, sem stack de middleware, sem regex no router. Slim ganha RAM de pico na escrita; Flight ganha RAM de pico na leitura. O p99 dos inserts é lock do SQLite WAL com 4 workers, não o matcher.
O mesmo app Stem no php -S: 349 req/s na leitura. O worker FrankenPHP entrega ~21× isso, porque a árvore de rotas fica residente.
Roda + Sequel (Puma, mesmo Docker, mesmo wrk) ainda lidera vazão bruta (19.807 req/s na leitura) com cerca de 2× a RAM de pico do Stem (133 MB vs 66 MB). O argumento do Stem em PHP é a tabela Flight/Slim, não ganhar do MRI + oj.
php -S 127.0.0.1:8080 index.php
wrk -t4 -c20 -d10s --latency http://127.0.0.1:8080/customers
| Classe | Papel |
|---|---|
Stem\App | route, handle, run, notFound, error. |
Stem\Request | HTTP + contexto de roteamento. |
Stem\Response | Status, headers, body, cookies, send(). |
Stem\Router | Cursor interno de path (@internal). |
Stem\Exceptions\HaltException | Só o halt(). |
Stem\Integrations\FrankenPhpWorker | Loop opcional de worker. |