ohffs/simple-reactphp-router

Simple attribute-based router wrapper for ReactPHP HTTP server

Installs: 1

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/ohffs/simple-reactphp-router

1.1.0 2025-12-13 13:04 UTC

This package is auto-updated.

Last update: 2025-12-13 13:06:59 UTC


README

A minimal, attribute-based HTTP routing wrapper for ReactPHP. Inspired by FastAPI's decorator syntax, optimised for the common case of simple API endpoints.

Installation

composer require ohffs/simple-reactphp-router

Quick Start

<?php

require __DIR__ . '/vendor/autoload.php';

use Ohffs\SimpleReactphpRouter\Attributes\Get;
use Ohffs\SimpleReactphpRouter\Attributes\Post;
use Ohffs\SimpleReactphpRouter\Request;
use Ohffs\SimpleReactphpRouter\Response;
use Ohffs\SimpleReactphpRouter\Server;
use Psr\Http\Message\ResponseInterface;

class ApiController
{
    #[Get('/health')]
    public function health(Request $request): ResponseInterface
    {
        return Response::json(['status' => 'ok']);
    }

    #[Get('/hello')]
    public function hello(Request $request): ResponseInterface
    {
        $name = $request->query('name', 'World');
        return Response::json(['message' => "Hello, {$name}!"]);
    }

    #[Post('/users')]
    public function createUser(Request $request): ResponseInterface
    {
        $data = $request->json();

        if ($data === null) {
            return Response::badRequest('Invalid JSON');
        }

        // Create user...

        return Response::json(['created' => true], 201);
    }
}

$server = new Server(new ApiController());
$server->run('127.0.0.1', 8000);

Run your server:

php server.php

Test it:

curl http://localhost:8000/health
# {"status":"ok"}

curl http://localhost:8000/hello?name=Jenny
# {"message":"Hello, Jenny!"}

curl -X POST http://localhost:8000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"John"}'
# {"created":true}

Features

  • Simple - Minimal API, no magic
  • Attribute-based routing - Use PHP 8 attributes to define routes
  • Route parameters - Support for {id} style parameters with optional regex constraints
  • Lightweight - Just ReactPHP and FastRoute, nothing else
  • Familiar - If you've used FastAPI or similar frameworks, you'll feel at home
  • Optional authentication - Simple bearer token auth built-in

Route Attributes

use Ohffs\SimpleReactphpRouter\Attributes\Get;
use Ohffs\SimpleReactphpRouter\Attributes\Post;
use Ohffs\SimpleReactphpRouter\Attributes\Put;
use Ohffs\SimpleReactphpRouter\Attributes\Patch;
use Ohffs\SimpleReactphpRouter\Attributes\Delete;

class MyController
{
    #[Get('/items')]
    public function list(Request $request): ResponseInterface { }

    #[Post('/items')]
    public function create(Request $request): ResponseInterface { }

    #[Put('/items/{id}')]
    public function replace(Request $request): ResponseInterface { }

    #[Patch('/items/{id}')]
    public function update(Request $request): ResponseInterface { }

    #[Delete('/items/{id}')]
    public function delete(Request $request): ResponseInterface { }
}

Route Parameters

Routes can include dynamic parameters using {name} syntax:

class UserController
{
    #[Get('/users/{id}')]
    public function show(Request $request): ResponseInterface
    {
        $id = $request->param('id');
        return Response::json(['user_id' => $id]);
    }

    #[Get('/users/{userId}/posts/{postId}')]
    public function showPost(Request $request): ResponseInterface
    {
        $userId = $request->param('userId');
        $postId = $request->param('postId');
        return Response::json(['user' => $userId, 'post' => $postId]);
    }
}

Regex Constraints

You can add regex constraints to parameters using {name:pattern} syntax:

class ItemController
{
    // Only matches numeric IDs
    #[Get('/items/{id:\d+}')]
    public function show(Request $request): ResponseInterface
    {
        $id = $request->param('id');
        return Response::json(['item_id' => $id]);
    }

    // Only matches slugs (lowercase letters and hyphens)
    #[Get('/posts/{slug:[a-z\-]+}')]
    public function showBySlug(Request $request): ResponseInterface
    {
        $slug = $request->param('slug');
        return Response::json(['slug' => $slug]);
    }
}

Requests that don't match the constraint will return a 404 response.

Request Helper

The Request class wraps PSR-7's ServerRequestInterface with convenience methods:

// Route parameters: /users/{id} matched against /users/123
$request->param('id');            // '123'
$request->param('missing', 'default'); // 'default'
$request->params();               // ['id' => '123']

// Query parameters: /path?foo=bar&baz=qux
$request->query('foo');           // 'bar'
$request->query('missing', 'default'); // 'default'
$request->queryAll();             // ['foo' => 'bar', 'baz' => 'qux']

// JSON body (decoded)
$request->json();                 // ['key' => 'value'] or null if invalid

// Raw body
$request->body();                 // '{"key":"value"}'

// Headers (case-insensitive)
$request->header('Content-Type'); // 'application/json'
$request->header('X-Missing', 'default'); // 'default'

// Bearer token from Authorization header
$request->bearerToken();          // 'your-token' or null

// HTTP method and path
$request->method();               // 'POST'
$request->path();                 // '/users'

// Access underlying PSR-7 request
$request->psrRequest();

Response Helper

The Response class provides static factory methods for common responses:

// JSON responses
Response::json(['status' => 'ok']);           // 200
Response::json(['created' => true], 201);     // 201

// No content
Response::noContent();                         // 204

// Plain text
Response::text('Hello, World!');              // 200 text/plain
Response::text('Accepted', 202);              // 202

// HTML
Response::html('<h1>Hello</h1>');             // 200 text/html

// Generic response with custom headers
Response::make('body', 200, ['X-Custom' => 'header']);

// Error responses
Response::notFound();                          // 404 {"error":"Not found"}
Response::notFound('User not found');          // 404 {"error":"User not found"}
Response::badRequest();                        // 400 {"error":"Bad request"}
Response::badRequest('Invalid email');         // 400 {"error":"Invalid email"}
Response::error();                             // 500 {"error":"Internal server error"}
Response::error('Service unavailable', 503);   // 503

Multiple Controllers

You can register multiple controller classes:

$server = new Server([
    new UserController(),
    new ProductController(),
    new HealthController(),
]);
$server->run();

Authentication

You can protect your API with a bearer token:

$server = new Server(new ApiController(), bearerToken: 'your-secret-token');
$server->run();

When a bearer token is set, all requests must include a valid Authorization header:

curl http://localhost:8000/health \
  -H "Authorization: Bearer your-secret-token"

Requests without a valid token receive a 401 Unauthorized response:

{"error": "Unauthorized"}

If no bearer token is configured, all requests are allowed (the default behaviour).

Error Handling

  • 404 Not Found - Returned when no route matches the path
  • 405 Method Not Allowed - Returned when the path exists but the HTTP method doesn't match
  • 500 Internal Server Error - Returned when an exception is thrown in a handler (exception is logged to stderr)

Configuration

The run() method accepts host and port:

$server->run();                    // 127.0.0.1:8000 (default)
$server->run('0.0.0.0', 8080);     // Listen on all interfaces, port 8080

Requirements

  • PHP 8.1+
  • ReactPHP HTTP ^1.11
  • FastRoute ^1.3

Development

Clone the repository:

git clone https://github.com/ohnotnow/simple-reactphp-router.git
cd simple-reactphp-router
composer install

Run tests:

./vendor/bin/pest

Run the example server:

php examples/simple-api.php

Design Decisions

  • Duplicate routes throw exceptions - Fail fast rather than silently overwriting
  • Trailing slashes are normalised - /users and /users/ are treated the same
  • Paths are case-sensitive - /Users and /users are different routes
  • Handlers must return ResponseInterface - Explicit over implicit

License

MIT License. See LICENSE for details.