StemPHP docs
EN PT

alencarfreire/stem · v0.3.0 · PHP 8.3+

Uma API JSON num arquivo só.

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.

  • PHP-FPM
  • php -S
  • FrankenPHP
  • RoadRunner
  • Swoole

Instalar

BASH
composer require alencarfreire/stem:^0.3

PHP 8.3+. Zero dependências de runtime. FrankenPHP, RoadRunner e Swoole são opcionais.

Início rápido

Crie index.php ao lado de vendor/. É uma API de users: listar, criar, mostrar, posts aninhados, apagar, 404 em JSON.

PHP
<?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):

BASH
php -S localhost:8080 index.php

Experimente

O mesmo processo, HTTP de verdade. Os status nos comentários são o que o Stem devolve.

Health

HTTP
curl -s localhost:8080/
200
{ "ok": true, "name": "StemPHP" }

Lista com query string

HTTP
curl -s 'localhost:8080/users?page=2'
200
{
  "page": 2,
  "data": [
    { "id": 1, "name": "Ada" },
    { "id": 2, "name": "Linus" }
  ]
}

Criar

HTTP
curl -s -X POST localhost:8080/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"Grace"}'
201
{ "id": 3, "name": "Grace" }

Show, coleção aninhada, delete

BASH
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

Arquitetura (PDO)

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.

TREE
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:

BASH
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

PHP
$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

PHP
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

PHP
$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.

POST JSON

jsonBody() decodifica o body. JSON inválido vira halt(400). Body vazio é null.

PHP
$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);
    });
});

Bearer token

Valida uma vez no ramo; todos os verbos debaixo herdam o mesmo check.

PHP
$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));
});
HTTP
curl -s localhost:8080/admin
# 401 {"error":"unauthorized"}

curl -s localhost:8080/admin \
  -H 'Authorization: Bearer secret'
200
{ "ok": true }

Recursos aninhados

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.

PHP
$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.

Rotas em arquivos

run() monta outro function (Request $r) no path restante. Miss não sela, então o próximo run() ainda pode casar.

PHP
// 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');
});

404 e erros

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().

Testes sem HTTP

Não precisa subir servidor. Request::create + App::handle.

PHP
$response = $app->handle(Request::create(
    'POST',
    '/users',
    ['Content-Type' => 'application/json'],
    [],
    '{"name":"Grace"}',
));

assert($response->status() === 201);
assert($response->body() === '{"id":3,"name":"Grace"}');

Como o matching funciona

O closure de rotas roda a cada request. Cada matcher é um if que pode consumir o próximo segmento. Não há trie compilada.

  • Um hit roda o callback e sela aquele nível. Irmãos não rodam.
  • Miss total → 404.
  • Ramo tomado, path sobrando → 404. Método errado → 405 + Allow. Sem backtracking.

Matchers

ChamadaSignificado
$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 / patchMé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().

Callbacks e $r

Matchers 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().

PHP
$r->on('users', function () use ($r): void {
    $r->isInt(function (int $id) use ($r): void {
        $r->get(fn () => $r->json(['id' => $id]));
    });
});

Playground de path

A árvore do exemplo curto original. Clique numa request e veja consume / miss / folha.


        

Request

$r é a mensagem HTTP e o contexto de roteamento.

MétodoPapel
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.

Response

json / html / redirect / noContent escrevem aqui. HEAD casa o get(); o send() omite o body.

PHP
$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']);

Ciclo do App

PHP
$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.
  • Happy path (json / html) não lança.

Runtimes

PHP-FPM / php -S

Padrão. php -S localhost:8080 index.php, ou aponte o PHP-FPM para esse front controller.

FrankenPHP worker

Suba o $app uma vez. O fromGlobals() fica dentro do loop.

RoadRunner

PSR-7 → Request::createhandle() → status/headers/body. Veja examples/roadrunner/.

Swoole

O mesmo handle(). Exemplo: examples/swoole/server.php.

PHP
use Stem\Integrations\FrankenPhpWorker;

FrankenPhpWorker::run(
    $app,
    maxRequests: (int) ($_SERVER['MAX_REQUESTS'] ?? 0),
    collectEvery: 0,
);

Isolamento

Reutilize o App. Nunca reutilize o Request. Sem estado estático de request. fromGlobals() fica no handler, não no boot do worker.

Performance

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().

PHP
$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.

Medido: Stem vs Flight vs Slim

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étricaStemPHPFlightPHP 3Slim 4
Vazão7.282,90 req/s6.775,64 req/s6.250,74 req/s
Latência p501,88 ms2,16 ms2,40 ms
Latência p9959,44 ms47,37 ms46,09 ms
RAM idle32,11 MB33,88 MB44,81 MB
RAM pico66,46 MB51,62 MB61,61 MB
CPU média / pico276% / 281%318% / 322%348% / 354%

Escrita — POST /customers

MétricaStemPHPFlightPHP 3Slim 4
Vazão2.413,79 inserts/s2.221,66 inserts/s2.145,92 inserts/s
Latência p505,58 ms6,25 ms6,64 ms
Latência p99376,55 ms238,74 ms305,72 ms
RAM pico89,65 MB85,33 MB63,16 MB
CPU média / pico154% / 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.

BASH
php -S 127.0.0.1:8080 index.php
wrk -t4 -c20 -d10s --latency http://127.0.0.1:8080/customers

Superfície da API

ClassePapel
Stem\Approute, handle, run, notFound, error.
Stem\RequestHTTP + contexto de roteamento.
Stem\ResponseStatus, headers, body, cookies, send().
Stem\RouterCursor interno de path (@internal).
Stem\Exceptions\HaltExceptionSó o halt().
Stem\Integrations\FrankenPhpWorkerLoop opcional de worker.