PHPackages                             davidwyly/rxn - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. davidwyly/rxn

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

davidwyly/rxn
=============

Rxn — an opinionated, PSR-15-native JSON-only API micro-framework for PHP 8.2+. Schema-compiled DTO binding, RFC 7807 default.

34[2 PRs](https://github.com/davidwyly/rxn/pulls)PHPCI passing

Since Apr 29Pushed 3mo ago5 watchersCompare

[ Source](https://github.com/davidwyly/rxn)[ Packagist](https://packagist.org/packages/davidwyly/rxn)[ RSS](/packages/davidwyly-rxn/feed)WikiDiscussions master Synced 2w ago

READMEChangelogDependenciesVersions (15)Used By (0)

[![alt tag](https://camo.githubusercontent.com/fbc54ae90bdf40d0336c0b90b5a1081791e4219757ae67fc0a31ebff2da8bb31/687474703a2f2f692e696d6775722e636f6d2f6e75363342314a2e706e673f31)](https://camo.githubusercontent.com/fbc54ae90bdf40d0336c0b90b5a1081791e4219757ae67fc0a31ebff2da8bb31/687474703a2f2f692e696d6775722e636f6d2f6e75363342314a2e706e673f31)

#### An opinionated JSON micro-framework for PHP.

[](#an-opinionated-json-micro-framework-for-php)

##### Status: alpha. Targets PHP 8.2+.

[](#status-alpha-targets-php-82)

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](docs/design-philosophy.md) 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`](https://github.com/davidwyly/rxn-orm) — query builder + ActiveRecord-shaped layer.
- [`davidwyly/rxn-observe`](https://github.com/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
-----------

[](#at-a-glance)

 ```
flowchart TB
    Req["HTTP request"] --> Serve["App::serve(Router)"]
    Serve --> Router["Http/Router(explicit or attribute-driven)"]
    Router --> Pipeline["Middleware pipeline"]
    Pipeline --> Handler["Route handler+ DTO bind/validate"]
    Handler --> Resp["Response"]
    Resp -->|success| OK["application/json{data, meta}"]
    Resp -. uncaught exception .-> Fail["middleware exception handler"]
    Fail --> PD["application/problem+jsonRFC 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`](docs/index.md) for the full request sequence and per-subsystem deep dives.

Why Rxn
-------

[](#why-rxn)

Five motives drive every decision in the framework: **novelty, simplicity, interoperability, speed, and strict JSON.**

### 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

[](#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

[](#novelty)

Opinionated pieces worth naming:

- **Typed DTO binding + attribute-driven validation.** Declare `public function create_v1(CreateProduct $input): array`, give `CreateProduct` public 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 separate `routes.php` to drift out of sync.
- **Typed route constraints** (`{id:int}`, `{slug:slug}`, `{id:uuid}`, custom) so `/users/foo` falls 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 8594 `Deprecation:` / `Sunset:` headers. Multiple versions of the same logical endpoint coexist as distinct paths; `routes:check` knows 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's `RxnOrmCrudHandler` base or your own ~50-LOC class), and OpenAPI auto-generated from the same DTOs.
- **Compile-time route conflict detection.** `bin/rxn routes:check`flags 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:check`closes 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 `Secure` behind an HTTPS proxy.

### Simplicity

[](#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 `TestResponse` with PHPUnit- integrated fluent assertions — no web server, no curl, no process boundary. The ORM lives in a separate package ([`davidwyly/rxn-orm`](https://github.com/davidwyly/rxn-orm)) so the framework itself stays narrow.

### Speed

[](#speed)

Cross-framework HTTP throughput, PHP 8.4, `php -S` per-request worker mode (full table + methodology in [`bench/ab/CONSOLIDATION.md`](bench/ab/CONSOLIDATION.md)):

FrameworkGET /helloGET /products/{id}POST validPOST 422**rxn****21,530****21,080****27,160****25,690**symfony micro-kernel17,25015,97015,49015,650raw PHP (no framework)17,14016,93017,08017,120slim 413,80013,78013,46013,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](docs/design-philosophy.md) 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`](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

[](#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`](docs/design-philosophy.md). The cumulative scoreboard is in [`bench/ab/CONSOLIDATION.md`](bench/ab/CONSOLIDATION.md).

Quickstart
----------

[](#quickstart)

```
composer install
vendor/bin/phpunit          # 670 tests, 1490 assertions
bin/rxn help                # CLI subcommands
```

### Minimal app shape

[](#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:

```
