Skip to content

davidwyly/rxn

Repository files navigation

alt tag

An opinionated JSON micro-framework for PHP.

Status: alpha. Targets PHP 8.2+ and ships a Docker stack on PHP 8.3-fpm.

Rxn (from "reaction") is built around 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. Frontends — web, mobile, whatever — build against the versioned contracts and stay decoupled.

The framework aims, in order, to be fast, small, and easy to use. The ORM / query builder lives in a separate package — davidwyly/rxn-orm — pulled in automatically via Composer.

At a glance

flowchart TB
    Req["HTTP request"] --> Index["public/index.php"]
    Index --> App["App::run"]
    App --> Boot["Startup<br/>.env + autoload + DBs"]
    App --> Dispatch{"route?"}
    Dispatch -->|convention| ConvCtrl["Versioned controller"]
    Dispatch -->|explicit| Router["Http/Router"]
    Router --> Pipeline["Middleware pipeline"]
    Pipeline --> ExplCtrl["Route handler"]
    ConvCtrl --> Resp["Response"]
    ExplCtrl --> Resp
    Resp -->|success| OK["application/json<br/>{data, meta}"]
    Resp -. uncaught exception .-> Fail["Response::getFailure"]
    Fail --> PD["application/problem+json<br/>RFC 7807"]
Loading

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-7 / PSR-15 bridge lets any ecosystem middleware drop in via Psr15Pipeline.

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.
  • Reflection-driven OpenAPI — 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.
  • 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

Small enough to read end to end. Dependency-free, injectable-for-test middlewares for the common defensive layers: CORS with preflight, request-id correlation, JSON-body decoding with size caps, conditional GET via weak ETags. 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) so the framework itself stays narrow.

Speed

  • PSR-4 autoloading; no reflection on the hot path once classes load.
  • 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 already gives you concurrent-requests concurrency without the Fibers + event-loop
    • non-blocking-driver tax, and every "why async" benchmark you see is really an "I'm IO-bound and never cached anything" story. Stack RoadRunner or Swoole under Rxn if you need in-request concurrency; the framework doesn't change shape for it.

Quickstart

composer install
vendor/bin/phpunit          # run the test suite
composer validate --strict  # sanity-check composer.json
bin/rxn help                # list CLI subcommands

Full Docker stack (PHP 8.3-fpm + nginx 1.27 + MySQL 8):

cp docker-compose.env.example .env
# edit .env: set MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD
docker compose up --build

Set INSTALL_XDEBUG=1 in .env to build the PHP image with Xdebug 3.

CI runs lint + phpunit against PHP 8.2, 8.3, and 8.4 plus an end-to-end HTTP smoke job against MySQL 8 (.github/workflows/ci.yml).

Current test counts:

  • Rxn framework: 230 tests / 521 assertions (vendor/bin/phpunit).
  • davidwyly/rxn-orm (query builder): 68 tests / 132 assertions, run in that repo.

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
CLI (bin/rxn) docs/cli.md
Benchmarks (bin/bench) docs/benchmarks.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)
  • 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-Proto proxy)
    • Stack traces never leave the server in production — Response::getFailure strips file / line / trace fields when ENVIRONMENT=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)
    • CSRF synchronizer tokens (Session::token() / Session::validateToken()) with constant-time compare
    • Bearer-token authentication (Service\Auth): the framework extracts + verifies, the app supplies the token → principal resolver — by design, not a gap
    • Rate limiting (Utility\RateLimiter, file-backed with flock)
  • 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 as x-rxn-* extension members
  • Versioning (versioned controllers + actions)
  • Scaffolding (version-less CRUD against a live schema)
  • URI Routing
    • Convention-based (/v{N}/{controller}/{action}/key/value/...)
    • Explicit pattern routing (Rxn\Framework\Http\Router)
    • 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
    • Attribute-based routing — #[Route('GET', '/products/{id:int}')] + #[Middleware(Auth::class)] directly on controller methods via Rxn\Framework\Http\Attribute\Scanner; no separate route table
    • Apache 2 (.htaccess)
    • NGINX (see docker/nginx)
  • 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
  • Object-Relational Mapping
    • Query builder (SELECT / INSERT / UPDATE / DELETE with subqueries, upsert, RETURNING) — ships as davidwyly/rxn-orm
    • ActiveRecord hydration + hasMany / hasOne / belongsTo relationships (Rxn\Framework\Model\ActiveRecord)
    • Scaffolded CRUD on a record (CrudController + Record)
    • FK relationship graph (Data\Chain + Link)
  • HTTP middleware pipeline (both Rxn-native and PSR-15; see Http\Pipeline / Http\Psr15Pipeline)
    • Shipped middlewares: CORS w/ preflight, request-id correlation, JSON-body decoding with size caps, conditional GET via weak ETags + 304 short-circuit (see Http\Middleware\{Cors,RequestId,JsonBody,ETag})
  • PSR-7 bridge (Http\PsrAdapter::serverRequestFromGlobals() / ::emit(); ecosystem middleware drops in via Psr15Pipeline)
  • 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 (*.sql runner)
  • Mailer (out of scope; use symfony/mailer or phpmailer)
  • Request validation (rule-based Validator::assert; see Rxn\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 7807 errors extension member.
  • 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 requestBody with 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
  • 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.