davidwyly / rxn
Rxn — an opinionated, PSR-15-native JSON-only API micro-framework for PHP 8.2+. Schema-compiled DTO binding, RFC 7807 default.
Requires
- php: ^8.2
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- vlucas/phpdotenv: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- davidwyly/rxn-observe: ^0.1 — OpenTelemetry / observability listeners for the framework's PSR-14 event surface (`Rxn\Framework\Observability\Event\*`). Drop the `OpenTelemetryListener` in and every request emits a span tree (root request → middleware children → handler), with binder + validation as span events.
- davidwyly/rxn-orm: ^0.1 — Rxn's query builder + ActiveRecord-shaped layer. Lives in a separate package so the framework itself stays narrow; install it when you reach for the data / model layer.
- psr/simple-cache: ^3.0 — required only when using `Psr16IdempotencyStore` to plug an existing PSR-16 cache (Redis / Memcached / APCu) into the Idempotency middleware. Apps using `FileIdempotencyStore` (the default) don't need this.
- dev-master
- dev-feat/examples-quickstart
- dev-chore/strip-convention-router
- dev-chore/note-rxn-observe-plugin
- dev-codex/fix-migrate-status-error-handling-f1r32i
- dev-codex/fix-migrate-status-error-handling
- dev-docs/openapi-snapshot-followup
- dev-claude/post-merge-corrections
- dev-bench/ab-compiled-json-encoder
- dev-bench/ab-pipeline-no-array-reverse
- dev-bench/ab-router-combined-alternation
- dev-bench/ab-psr-adapter-factory-cache
This package is not auto-updated.
Last update: 2026-05-10 12:55:48 UTC
README
An opinionated JSON micro-framework for PHP.
Status: alpha. Targets PHP 8.2+.
Rxn (from "reaction") tries to land all three of fast, readable, and small at the same time. The usual trilemma ("pick two") is real only when the three are treated as orthogonal axes to optimise independently — they aren't. Bad design hurts all three at once; good design helps all three at once. The design philosophy doc is the working theory.
The operational consequence is a single opinion: strict backend/frontend decoupling. The backend is API-only, responds in JSON, and rolls up every uncaught exception into a JSON error envelope (RFC 7807 Problem Details). Frontends — web, mobile, whatever — build against the versioned contracts and stay decoupled. JSON-only is a narrowing decision that pays dividends down the stack: no content negotiation, no view layer, one error envelope.
Vendor-flavoured concerns ship as separate packages so the core stays narrow:
davidwyly/rxn-orm— query builder + ActiveRecord-shaped layer.davidwyly/rxn-observe— OpenTelemetry listener over the framework's PSR-14 event surface; drop in for span trees.
Both are opt-in via composer require; no plumbing in core, no
cost when not installed.
At a glance
flowchart TB
Req["HTTP request"] --> Serve["App::serve(Router)"]
Serve --> Router["Http/Router<br/>(explicit or attribute-driven)"]
Router --> Pipeline["Middleware pipeline"]
Pipeline --> Handler["Route handler<br/>+ DTO bind/validate"]
Handler --> Resp["Response"]
Resp -->|success| OK["application/json<br/>{data, meta}"]
Resp -. uncaught exception .-> Fail["middleware exception handler"]
Fail --> PD["application/problem+json<br/>RFC 7807"]
Loading
App::serve(Router) is the entry point — populate the Router
directly or from #[Route] attributes via Http\Attribute\Scanner.
The framework is boot-free: no constructor, no Container plumbing,
no DB connection during request setup.
See docs/index.md for the full request sequence
and per-subsystem deep dives.
Why Rxn
Five motives drive every decision in the framework: novelty, simplicity, interoperability, speed, and strict JSON.
Strict JSON
Every exit point — including uncaught exceptions — is a JSON
response. Slim / Lumen / Mezzio / API Platform all default to
JSON but still let controllers return HTML, XML, streams; that
flexibility forces a content-negotiation layer you can't opt out
of, and every app on top has to remember a surprising exception
can leak an HTML stack trace. Rxn removes the choice. Success
lands on {data, meta}; errors land on
application/problem+json. Two shapes, both machine-readable,
zero negotiation code.
Interoperability
Errors are RFC 7807 Problem Details, not a bespoke envelope.
API gateways, Problem Details-aware client libraries, and error
aggregators already understand the shape; Rxn just emits what the
ecosystem expects. OpenAPI 3 specs generate from reflection
(bin/rxn openapi), so the contract is always in sync with the
code — hand the spec to any OpenAPI consumer (Redocly, client
generators). Drop in Http\OpenApi\SwaggerUi::html($specUrl)
from a route handler for instant interactive docs.
PSR-native end-to-end. PSR-7 ingress (default), PSR-15
middleware (the contract for all eight shipped middlewares),
PSR-11 container, PSR-3 logger, PSR-14 events — every framework
interface satisfies the relevant PSR. Any third-party CORS /
OAuth / OpenTelemetry / JWT / rate-limit middleware drops into
the Pipeline; pass the container to a PSR-11-aware library;
subscribe a PSR-14 listener to IdempotencyHit for replay-rate
dashboards. No adapters, no escape hatches — the framework's
contracts are the PSR contracts.
Novelty
Opinionated pieces worth naming:
- Typed DTO binding + attribute-driven validation. Declare
public function create_v1(CreateProduct $input): array, giveCreateProductpublic typed properties with#[Required],#[Min(0)],#[Length(min: 1, max: 100)], etc., and the framework hydrates, casts, validates, and hands your action a populated instance — or fails the whole request with a 422 Problem Details listing every field error at once. The same FastAPI-class ergonomic move that almost nothing in the PHP ecosystem ships natively, in ~250 LoC with no DSL. - Attribute routing + middleware on the controller method:
#[Route('GET', '/products/{id:int}')]and#[Middleware(Auth::class)]are the route table. No separateroutes.phpto drift out of sync. - Typed route constraints (
{id:int},{slug:slug},{id:uuid}, custom) so/users/foofalls through to 404 instead of reaching a controller that has to validate and throw. - API versioning as a primitive —
#[Version('v1')]on a method (or class) prefixes the route's path;#[Version('v1', deprecatedAt: '…', sunsetAt: '…')]auto-attaches a middleware that emits RFC 8594Deprecation:/Sunset:headers. Multiple versions of the same logical endpoint coexist as distinct paths;routes:checkknows the difference between "intentional cross-version routes" and "real conflict." - CRUD scaffolding via Resource handlers — one call
(
ResourceRegistrar::register($router, '/products', $handler, …)) wires the full create/read/update/delete/search route family. Handler is a 5-method interface against any storage; framework does DTO binding, validation-failure → 422 wrapping, missing-row → 404, deleted → 204. The "extend a class, get five endpoints" ergonomic the convention router gave us — but with typed wire (DTOs, not arrays), pluggable storage (rxn-orm'sRxnOrmCrudHandlerbase or your own ~50-LOC class), and OpenAPI auto-generated from the same DTOs. - Compile-time route conflict detection.
bin/rxn routes:checkflags ambiguous#[Route]patterns before they ship —/items/{id:int}vs/items/{slug:slug}(slug accepts digits) is a real conflict; the runtime would silently let whichever was registered first win and leave the other as dead code. CI catches it instead. - Reflection-driven OpenAPI + snapshot contract gate — the
framework knows your controllers; why duplicate that in a YAML
file? DTO validation attributes map one-to-one to JSON Schema
keywords, so the spec can't drift from the runtime behaviour
— both sides read the same class.
bin/rxn openapi:checkcloses the loop in CI: regenerate spec → diff against the committed snapshot → fail the build on breaking changes (operation removed, type changed, constraint tightened, …) unless the PR opts in. Schema-as-truth becomes schema-as-governance. - Production-safe by default — stack traces never ship outside
dev, boundary input sanitisation is one env flag, session
cookies auto-flip to
Securebehind an HTTPS proxy.
Simplicity
Small enough to read end to end — ~11K LOC of framework code ships what a comparably-featured Slim or Mezzio composition reaches in 70–100K LOC of vendor packages (DTO binding + attribute-driven validation, OpenAPI from reflection, idempotency middleware with three storage shapes, RFC 7807 envelope, eight production middlewares, schema-compiled fast paths — all in one repository). Slim is small because it offloads to the ecosystem; Symfony is comprehensive because it doesn't. Rxn is small and feature-dense — the schema-as-truth principle (one DTO drives binding, validation, OpenAPI, and the compiled hydrator) is what makes that arithmetic work.
Dependency-free, injectable-for-test middlewares for the common
defensive layers: BearerAuth, CORS with preflight, ETag, JSON-
body decoding with size caps, Idempotency (Stripe-style replay),
Pagination, RequestId, TraceContext (W3C). DI container supports
interface-to-implementation binding ($c->bind(UserRepo::class, PostgresUserRepo::class)) and factory closures, so serious apps
aren't stuck with autowire-only. An in-process TestClient
(Rxn\Framework\Testing\TestClient) fires requests at your Router
- middleware stack and returns a
TestResponsewith PHPUnit- integrated fluent assertions — no web server, no curl, no process boundary. The ORM lives in a separate package (davidwyly/rxn-orm) so the framework itself stays narrow.
Speed
Cross-framework HTTP throughput, PHP 8.4, php -S per-request
worker mode (full table + methodology in
bench/ab/CONSOLIDATION.md):
| Framework | GET /hello | GET /products/{id} | POST valid | POST 422 |
|---|---|---|---|---|
| rxn | 21,530 | 21,080 | 27,160 | 25,690 |
| symfony micro-kernel | 17,250 | 15,970 | 15,490 | 15,650 |
| raw PHP (no framework) | 17,140 | 16,930 | 17,080 | 17,120 |
| slim 4 | 13,800 | 13,780 | 13,460 | 13,480 |
1.5–2× the throughput of Slim, 1.25–1.75× Symfony, and on
binder-driven POSTs 1.5–1.6× faster than hand-rolled raw PHP
doing the same json_decode + manual validation. The
schema-compiled fast paths (Validator::compile,
Binder::compileFor, container factory cache) earn the gap;
PSR-7 ingress + Binder::bindRequest(ServerRequestInterface)
closes another 33–42% on POST cells vs the previous superglobal
path. p50 latency on the binder-heavy POST cell: 0.71ms (Slim:
1.47ms; raw PHP: 1.14ms).
How that's possible (the design philosophy document is the long version):
- PSR-4 autoloading; no reflection on the hot path once caches warm. Container caches reflection / construction plans / parsed-name lookups and compiles per-class factory closures — five stacked optimisations, transparent, ~2.2× cumulative.
- Optional schema-compiled fast paths. For long-lived workers
(RoadRunner / Swoole / FrankenPHP),
Validator::compile($rules)runs 2.45× faster than the runtime path;Binder::compileFor($class)runs 6.4× faster. Same APIs, two performance profiles. - OPcache preload script (
bin/preload.php) for fpm cold-start latency. - File-backed query caching (
Database::setCache()) and object file caching with atomic writes for reflection-derived data. - ETag middleware drops 304s for unchanged GETs before your controller serializes a byte of response.
- No content-negotiation layer to walk on every request.
- Sync-first, process-per-request, predictable. We deliberately don't chase async — PHP-FPM's process pool gives you concurrent- requests concurrency without the Fibers + event-loop + non-blocking-driver tax. Stack RoadRunner or Swoole under Rxn if you need in-request concurrency; the framework doesn't change shape for it (and the compile-path opt-ins above start paying for themselves there).
How it stays this way
Every shipped optimisation has an A/B run with worktree-based
comparison, ranges, and a non-overlapping-range verdict
(bench/ab.php). Negative-result branches stay on origin with
their writeups. Sixteen experiments documented; eleven merged,
four documented as negative results, one shipped as
infrastructure.
The principles that produced those eleven wins are written down
in docs/design-philosophy.md. The
cumulative scoreboard is in
bench/ab/CONSOLIDATION.md.
Quickstart
composer install vendor/bin/phpunit # 670 tests, 1490 assertions bin/rxn help # CLI subcommands
Minimal app shape
The framework's entry point is App::serve(Router). Boot-free —
no constructor, no Container plumbing, no DB connection during
request setup. A complete app:
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Rxn\Framework\App; use Rxn\Framework\Http\Router; $router = new Router(); $router->get('/products/{id:int}', function (array $params): array { return ['data' => ['id' => (int) $params['id']]]; }); App::serve($router);
Drop in front of php -S for local dev, or php-fpm + nginx /
Caddy / Apache for production. Apps that need DI / config /
logging wire those at the composition root and inject them into
handlers — the framework itself stays out of that decision.
Worked example — examples/quickstart/
270 LOC across three files exercising the full modern stack:
DTO binding + attribute validation, BearerAuth, RequestId,
HealthCheck, typed route constraints ({id:int}), RFC 7807
Problem Details on every failure path. File-backed JSON repo
(flock-protected) so no database is needed.
php -S 127.0.0.1:9871 -t examples/quickstart/public
See examples/quickstart/README.md
for the full curl walkthrough.
CI / cross-framework comparison
CI runs lint + phpunit against PHP 8.2, 8.3, and 8.4
(.github/workflows/ci.yml).
Test counts:
- Rxn framework: 670 tests / 1490 assertions (
vendor/bin/phpunit). davidwyly/rxn-orm(query builder): 68 tests / 132 assertions, run in that repo.davidwyly/rxn-observe(OpenTelemetry listener): 9 tests / 26 assertions, run in that repo.
Cross-framework comparison harness
(bench/compare/) benchmarks Rxn against Slim 4,
a Symfony micro-kernel, and a raw-PHP baseline on identical routes
— pure PHP, no Docker. The full table is in
bench/ab/CONSOLIDATION.md; the
methodology is in
bench/compare/README.md.
Documentation
| Topic | Where |
|---|---|
| Routing (convention + explicit patterns) | docs/routing.md |
| Dependency injection | docs/dependency-injection.md |
| Request binding + validation | docs/request-binding.md |
| Scaffolded CRUD | docs/scaffolding.md |
| Error handling | docs/error-handling.md |
| Building blocks (Logger, RateLimiter, Scheduler, Auth, Pipeline, Router, Validator, Migration, Chain, query cache, PSR-7 bridge) | docs/building-blocks.md |
| PSR-7 / PSR-15 interop — bench evidence behind the ingress cost analysis (page predates the PSR-15 native migration; see CHANGELOG) | docs/psr-7-interop.md |
| Plugin architecture — what lives in core vs. as separate Composer packages | docs/plugin-architecture.md |
| Horizons — research directions that could reposition the framework, each sized with cost and ship signal | docs/horizons.md |
CLI (bin/rxn) |
docs/cli.md |
Benchmarks (bin/bench) |
docs/benchmarks.md |
| Cross-framework comparison (Slim / Symfony / raw) | bench/compare/README.md |
| Contribution / style guide | CONTRIBUTING.md |
Features
[X] = implemented and shipped, [ ] = on the roadmap.
- 80%+ unit test code coverage (currently minimal; see
src/Rxn/**/Tests/for what's covered) - Gentle learning curve
- Installation through Composer
- Simple workflow with an existing database schema
- Code generation
- CLI utility to create controllers and models
(
bin/rxn make:controller,bin/rxn make:record)
- CLI utility to create controllers and models
(
- Code generation
- Database abstraction
- PDO for multiple database support
- Support for multiple database connections
- Security
- Prepared statements everywhere — user values flow only through PDO bindings; identifiers come from schema reflection, never from request data
- Session cookies set with HttpOnly + SameSite=Lax; Secure
flag flips on automatically when the request is HTTPS
(including behind a trusted
X-Forwarded-Protoproxy) - Stack traces never leave the server in production —
Response::getFailurestrips file / line / trace fields whenENVIRONMENT=production - Boundary input sanitization — control-character
stripping on every GET / POST / header param when
APP_USE_IO_SANITIZATION=true(JSON is the output format, so HTML-escaping stays in the frontend) - Bearer-token authentication (
Http\Middleware\BearerAuth): the framework extracts + verifies, the app supplies the token → principal resolver — by design, not a gap - Rate limiting (
Utility\RateLimiter, file-backed withflock)
- Exception-driven error handling
- RFC 7807 Problem Details (
application/problem+json) is the error shape, period — uncaught exceptions included. Dev-mode file/line/trace carry asx-rxn-*extension members
- RFC 7807 Problem Details (
- URI Routing — explicit
Http\Router, driven directly or populated from#[Route]attributes- Attribute-based routing —
#[Route('GET', '/products/{id:int}')]+#[Middleware(Auth::class)]directly on controller methods viaRxn\Framework\Http\Attribute\Scanner; no separate route table - Explicit pattern routing (
Rxn\Framework\Http\Router) when attributes don't fit (programmatic groups, route generation from data, etc.) - Typed route constraints (
{id:int},{slug:slug},{id:uuid}, custom) — a non-matching URL just falls through to 404 instead of bubbling up as a controller-level validation error - Compile-time route conflict detection (
bin/rxn routes:check;Http\Routing\ConflictDetector) — flags ambiguous#[Route]patterns at CI time using a constraint-type compatibility matrix (e.g.{id:int}vs{slug:slug}both accept"123"→ conflict;{id:int}vs{name:alpha}are disjoint). Exit 1 = conflicts found. No PHP framework I know of catches this before first-request resolution
- Attribute-based routing —
- Dependency Injection container
- Controller method injection
- DI autowiring via constructor type hints
- Interface → implementation binding
(
$container->bind($abstract, $concrete)) and factory closures ($container->bind($abstract, fn ($c) => ...)) - Circular-dependency detection
- Database / ORM concerns ship as
davidwyly/rxn-orm(query builder + ActiveRecord).composer requireto opt in; no plumbing in core. - HTTP middleware pipeline — PSR-15 native end-to-end
(
Http\Pipeline; all eight shipped middlewares implementPsr\Http\Server\MiddlewareInterface)- Shipped middlewares: BearerAuth, CORS w/ preflight,
conditional GET via weak ETags + 304 short-circuit,
Idempotency (Stripe-style replay), JSON-body decoding with
size caps, Pagination + RFC 8288 Link headers, request-id
correlation, W3C Trace Context (auto-propagation to
outbound
Concurrency\HttpClientcalls) (Http\Middleware\*)
- Shipped middlewares: BearerAuth, CORS w/ preflight,
conditional GET via weak ETags + 304 short-circuit,
Idempotency (Stripe-style replay), JSON-body decoding with
size caps, Pagination + RFC 8288 Link headers, request-id
correlation, W3C Trace Context (auto-propagation to
outbound
- PSR-7 ingress —
Http\PsrAdapter::serverRequestFromGlobals()builds aServerRequestInterfacefrom PHP globals;App::serve(Router)is the boot-free one-line front controller - PSR-11 container, PSR-3 logger, PSR-14 event dispatcher — every framework interface satisfies the relevant PSR; drops into existing PSR-aware codebases without an adapter
- Speed and performance
- PSR-4 autoloading
- File-backed query caching
- Object file caching (atomic writes)
- Event logging (JSON-lines)
- Scheduler (interval / predicate based)
- Database migrations (
*.sqlrunner) - Mailer (out of scope; use symfony/mailer or phpmailer)
- Request validation (rule-based
Validator::assert; seeRxn\Framework\Utility\Validator)- Typed DTO binding with attribute-driven validation —
Http\Binding\RequestDto+Http\Binding\Binder+#[Required],#[Min],#[Max],#[Length],#[Pattern],#[InSet]. All errors surface at once as a 7807errorsextension member.
- Typed DTO binding with attribute-driven validation —
- OpenAPI 3 spec generation from reflected controllers
(
bin/rxn openapi;Http\OpenApi\Generator+Discoverer)- One-line interactive docs via
Http\OpenApi\SwaggerUi::html($specUrl) - DTO parameters emit as
requestBodywith validation attributes mapped to JSON Schema keywords (#[Min]→minimum,#[Length]→minLength/maxLength,#[Pattern]→pattern,#[InSet]→enum) — the same class drives both validation and the spec, so they can't drift - Snapshot-tested contract gate (
bin/rxn openapi:check;Codegen\Snapshot\OpenApiSnapshot) — diffs the generated spec against a committedopenapi.snapshot.jsonand classifies drift as breaking (operation/parameter/property removals, type changes, tightened constraints, required- toggles) or additive (new operations, new optional fields, loosened constraints). Three exit codes for CI gating: 0 clean, 1 additive only or--allow-breakingoverride, 2 breaking detected - Cross-language validator twin (
Codegen\JsValidatorEmitter) — emits a vanilla ES module that agrees withBinder::bindon 0 disagreements / 10K random inputs across four fixture DTOs per CI run - Polyparity
YAML exporter (
Codegen\PolyparityExporter) — same DTO drives Rxn's PHP server, the JS twin, AND a polyparity spec consumable by polyparity's TS / Python / future- language siblings
- One-line interactive docs via
- In-process HTTP test client + fluent response assertions
(
Testing\TestClient+TestResponse) — no web server, no curl, PHPUnit-integrated failures - Automated API request validation from contracts
- Optional, modular plug-ins
License
Rxn is released under the permissive MIT license.