StemPHP docs
EN PT

alencarfreire/stem · v0.3.0 · PHP 8.3+

A JSON API in one file.

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.

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

Install

BASH
composer require alencarfreire/stem:^0.3

PHP 8.3+. Zero runtime dependencies. FrankenPHP, RoadRunner and Swoole are optional.

Quick start

Create index.php next to vendor/. This is a small users API: list, create, show, nested posts, delete, JSON 404.

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

Start the built-in server with that file as the router (otherwise /users is looked up as a file on disk):

BASH
php -S localhost:8080 index.php

Try it

Same process, real HTTP. Status codes in comments are what Stem returns.

Health

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

List with query string

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

Create

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

Show, nested collection, 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

Architecture (PDO)

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.

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

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"}'

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

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

Repository — no 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 */;
}

Route — load once on the branch

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

POST JSON

jsonBody() decodes the raw body. Invalid JSON is halt(400). Empty body is 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

Load the user (or reject) once on the branch, then every nested verb sees the same 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 }

Nested resources

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.

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 → nested. GET /users/1/nope → 404. PUT /users/1 → 405 + Allow: DELETE, GET, HEAD.

Split route files

run() mounts another function (Request $r) on the current remaining path. A miss does not seal, so the next run() can still match.

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 and errors

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

Tests without HTTP

No need to boot a server. 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"}');

How matching works

The route closure runs on every request. Each matcher is an if that may consume the next segment. There is no compiled trie.

  • A hit runs the callback, then seals that level. Siblings do not run.
  • Total miss → 404.
  • Branch taken, leftover path → 404. Wrong method → 405 + Allow. No backtracking.

Matchers

CallMeaning
$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 / patchHTTP 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().

Callbacks and $r

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

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

Path playground

Same tree as the original mini example. Click a request and watch consume / miss / leaf.


        

Request

$r is the HTTP message and the routing context.

MethodRole
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.

Response

json / html / redirect / noContent write here. HEAD matches get(); send() omits the body.

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

PHP
$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.
  • Happy path (json / html) does not throw.

Runtimes

PHP-FPM / php -S

Default. php -S localhost:8080 index.php, or point PHP-FPM at that front controller.

FrankenPHP worker

Boot $app once. fromGlobals() stays inside the loop.

RoadRunner

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

Swoole

Same handle() contract. Example: examples/swoole/server.php.

PHP
use Stem\Integrations\FrankenPhpWorker;

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

Isolation

Reuse App. Never reuse Request. No static request state. fromGlobals() belongs in the handler, not at worker boot.

Performance

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

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

Measured: Stem vs Flight vs Slim

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

MetricStemPHPFlightPHP 3Slim 4
Throughput7,282.90 req/s6,775.64 req/s6,250.74 req/s
Latency p501.88 ms2.16 ms2.40 ms
Latency p9959.44 ms47.37 ms46.09 ms
RAM idle32.11 MB33.88 MB44.81 MB
RAM peak66.46 MB51.62 MB61.61 MB
CPU avg / peak276% / 281%318% / 322%348% / 354%

Write — POST /customers

MetricStemPHPFlightPHP 3Slim 4
Throughput2,413.79 inserts/s2,221.66 inserts/s2,145.92 inserts/s
Latency p505.58 ms6.25 ms6.64 ms
Latency p99376.55 ms238.74 ms305.72 ms
RAM peak89.65 MB85.33 MB63.16 MB
CPU avg / peak154% / 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.

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

API surface

ClassRole
Stem\Approute, handle, run, notFound, error.
Stem\RequestHTTP + routing context.
Stem\ResponseStatus, headers, body, cookies, send().
Stem\RouterInternal path cursor (@internal).
Stem\Exceptions\HaltExceptionOnly halt().
Stem\Integrations\FrankenPhpWorkerOptional worker loop.