rockberpro/rosa-router

Smart REST router for PHP

Maintainers

Package info

github.com/rockberpro/rosa-router

pkg:composer/rockberpro/rosa-router

Transparency log

Statistics

Installs: 130

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


README

ROSA Router — PHP REST API Router

A smart, lightweight and fast REST router for PHP.

Tests Latest Version PHP Version License PSR-4

Table of Contents

Overview

ROSA Router is a lightweight and efficient REST API engine built with PHP. It handles incoming HTTP requests and routes them to the appropriate controllers or actions based on your defined endpoints. With a focus on simplicity and performance, ROSA Router lets you build and deploy RESTful web services quickly — in both stateless and stateful (long-running server) modes.

Why ROSA Router?

Most PHP routers force a choice up front: the classic stateless model — where the framework boots from scratch on every request — or a stateful, long-running server for lower latency. ROSA Router runs the same route definitions in both modes, so you can start simple on a shared host and later switch to a persistent ReactPHP server by changing a single flag — no rewrite.

On top of that it stays dependency-light and explicit: a clean routing syntax, first-class middleware, and a JWT utility to build your own auth on, without pulling in a full framework.

Features

  • 🚀 Easy routing — Define routes for your REST API with a clean, expressive syntax.
  • 🔀 Full HTTP method supportGET, POST, PUT, PATCH and DELETE.
  • 🧩 Route groups & prefixes — Organize routes with prefixes, nesting and namespaces.
  • 🛡️ Middleware — Attach middleware to single routes or whole groups.
  • 🔐 JWT utility — Issue and verify JSON Web Tokens; enforce them in your own middleware.
  • 📝 Request logging — Opt-in per-route logging to file or database.
  • Stateless or stateful — Run on a classic web server or as a long-running ReactPHP server.
  • 🪶 Lightweight & fast — Minimal overhead, optimized for performance.
  • 🧯 Built-in error handling — Gracefully manage exceptions and invalid requests.

Requirements

  • PHP 8.0 or higher
  • Composer
  • Extensions: ext-json, ext-pdo

Installation

composer require rockberpro/rosa-router

Configuration

ROSA Router is configured through environment variables loaded at bootstrap. Copy the example file and adjust it for your environment:

cp .env.example .env

Bootstrap::setup() loads the configuration automatically. You can point it at a custom path — both .env and .ini formats are supported:

Bootstrap::setup();               // loads ./.env
Bootstrap::setup('config/.env');  // custom path
Bootstrap::setup('config/.ini');  // INI format
Variable Description Example
API_NAME Application name rosa-api
API_DEBUG Verbose error output false
API_LOGS Enable built-in file/stream request logging (see Logging) true
API_ALLOW_ORIGIN CORS allowed origin *
API_SERVER_ADDRESS / API_SERVER_PORT Address & port for stateful mode 0.0.0.0 / 8081
JWT_ISSUER / JWT_SUBJECT / JWT_SECRET JWT signing settings (see Authentication)

How It Works

ROSA Router listens for HTTP requests and maps them to the correct route handler based on the request's method and URI. It supports both static and dynamic routes and is fully customizable to fit different project needs.

Quick Start

Stateless mode (web server)

<?php
// index.php

use Rockberpro\RosaRouter\Bootstrap;

require_once "vendor/autoload.php";

// Bootstrap::setup('path/to/.env');
// Bootstrap::setup('path/to/.ini');
Bootstrap::setup();

$server = Server::init();
// '/api' is the default base prefix — pass a different one to change it
// (see the "Base prefix" section). isApiEndpoint() runs first, so it must
// be given the same prefix.
if ($server->isApiEndpoint('/api')) {
    $server->loadRoutes('./routes/api.php', '/api');
    $server->execute(Server::MODE_STATELESS);
}

Stateful mode (long-running server)

<?php
// server.php — run with: php server.php

use Rockberpro\RosaRouter\Utils\DotEnv;
use Rockberpro\RosaRouter\Bootstrap;
use React\Socket\SocketServer;
use React\Http\HttpServer;

require_once "vendor/autoload.php";

// Bootstrap::setup('path/to/.env');
// Bootstrap::setup('path/to/.ini');
Bootstrap::setup();

$port = DotEnv::get('API_SERVER_PORT');
$address = DotEnv::get('API_SERVER_ADDRESS');

$server = Server::init();
// '/api' is the default base prefix — pass a different one to change it
$server->loadRoutes('./routes/api.php', '/api');

$server = new HttpServer(
    $server->execute(Server::MODE_STATEFUL)
);
$server->on('error', function (Throwable $e) {
    print("Request error: " . $e->getMessage() . PHP_EOL);
});

$socket = new SocketServer("{$address}:{$port}");
$server->listen($socket);

print("Server running at http://{$address}:{$port}" . PHP_EOL);

Usage

Basic routes

A route maps an HTTP method and URI to a handler. The handler can be a controller ([Controller::class, 'method'] or 'Controller@method') or an inline closure. Every handler must return a Response.

use Rockberpro\RosaRouter\Core\Route;

Route::get('/post/{post}/comment/{comment}', [PostController::class, 'get']);
Route::get('/user/{id}',                     [UserController::class, 'get']);
Route::post('/user',                         [UserController::class, 'post']);
Route::put('/user/{id}',                     [UserController::class, 'put']);
Route::patch('/user/{id}',                   [UserController::class, 'patch']);
Route::delete('/user/{id}',                  [UserController::class, 'delete']);

A handler can also be a closure that receives the Request and returns a Response:

use Rockberpro\RosaRouter\Core\Request;
use Rockberpro\RosaRouter\Core\Response;
use Rockberpro\RosaRouter\Core\Route;

Route::get('/ping', function (Request $request) {
    return new Response(['message' => 'pong'], Response::OK);
});

Reading request data

Inside a handler, use the Request instance to read incoming values. $request->get($key) resolves the key from body, path and query parameters (in that order):

Route::get('/user/{id}', function (Request $request) {
    $id     = $request->get('id');        // path parameter  -> /user/42
    $fields = $request->get('fields');    // query parameter -> ?fields=name,email

    return new Response(['id' => $id, 'fields' => $fields], Response::OK);
});

Need them grouped by source? Use the dedicated accessors:

$request->getPathParam('id');     // route placeholders, e.g. {id}
$request->getQueryParam('page');  // ?page=2
$request->getBodyParam('email');  // JSON body fields
$request->getParams();            // everything, grouped by source

Responses

A Response takes a payload (sent as JSON) and an HTTP status code. The Response class exposes constants for the common codes:

return new Response(['message' => 'Created'], Response::CREATED);       // 201
return new Response(['message' => 'Not found'], Response::NOT_FOUND);   // 404
return new Response(['message' => 'Invalid'], Response::UNPROCESSABLE_ENTITY); // 422

Base prefix

Every route is served under a base prefix that is prepended before any group prefix(). It defaults to /api, so Route::get('/status', ...) is reachable at /api/status.

Pass a second argument to loadRoutes() to change it. The prefix is scoped to that file, so different route files can be mounted under different roots:

$server->loadRoutes('./routes/api.php', '/v1');      // routes served under /v1
$server->loadRoutes('./routes/admin.php', '/admin'); // routes served under /admin

isApiEndpoint() reports whether the incoming request targets the base prefix. In stateless mode it runs before loadRoutes(), so give it the same prefix so the two agree:

if ($server->isApiEndpoint('/v1')) {
    $server->loadRoutes('./routes/api.php', '/v1');
    $server->execute(Server::MODE_STATELESS);
}

Passing '' (or '/') disables the base prefix, serving routes at the site root; with no base prefix every request is treated as an API endpoint.

Grouped routes

Use prefix() + group() to share a common URI segment across several routes instead of repeating it on each one:

Route::prefix('v1')->group(function() {
    Route::get('/users/{id}', [UserController::class, 'get']);  // GET  /api/v1/users/{id}
    Route::post('/users', [UserController::class, 'post']);     // POST /api/v1/users
});

A group is also where you attach a middleware() or namespace() once and have it apply to every route inside (see the Middleware and Namespaces sections).

Nested routes

Groups can be nested to any depth. Each nested prefix() is appended to its parent, so the final URI is the concatenation of every prefix in the chain, below the base prefix (/api by default):

Route::prefix('v1')->group(function() {

    Route::get('/users/{id}', [UserController::class, 'get']);   // GET  /api/v1/users/{id}
    Route::post('/users', [UserController::class, 'post']);      // POST /api/v1/users

    Route::prefix('users/{user}')->group(function() {
        Route::get('/posts', [PostController::class, 'index']);  // GET  /api/v1/users/{user}/posts
        Route::post('/posts', [PostController::class, 'store']); // POST /api/v1/users/{user}/posts
    });
});

Namespaces

Set a namespace() so you can reference controllers by their short Controller@method string instead of the fully-qualified class name:

Route::namespace('App\\Controllers')->group(function() {
    Route::get('/example', 'ExampleController@get');
    Route::post('/example', 'ExampleController@post');
});

namespace() composes with the other modifiers — combine it with prefix() and middleware() on the same group when you need to:

Route::prefix('v1')
    ->namespace('App\\Controllers')
    ->middleware(AuthMiddleware::class)
    ->group(function() {
        Route::get('/example', 'ExampleController@get');
    });

Middleware

A middleware implements MiddlewareInterface. Its handle() method receives the Request and a $next closure — call $next($request) to pass control along, or return a Response early to short-circuit the request:

namespace App\Middleware;

use Closure;
use Rockberpro\RosaRouter\Middleware\MiddlewareInterface;
use Rockberpro\RosaRouter\Core\Request;
use Rockberpro\RosaRouter\Core\Response;

class AuthMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!$request->get('token')) {
            return new Response(['message' => 'Access denied'], Response::UNAUTHORIZED);
        }

        return $next($request);
    }
}

Attach it to a single route or to a whole group:

// Single route
Route::middleware(AuthMiddleware::class)
    ->get('/hello', 'HelloWorldController@hello');

// Whole group
Route::prefix('v1')
    ->middleware(AuthMiddleware::class)
    ->namespace('App\\Controllers')
    ->group(function() {
        Route::get('/hello', 'HelloWorldController@hello');
    });

Middleware accumulates through nesting. When groups are nested, a route runs every middleware declared along its chain — an inner group does not discard the middleware inherited from an outer one. They execute outer-most first, and the same middleware declared at multiple levels runs only once:

Route::middleware(LogRequestMiddleware::class)   // applies to everything below
    ->group(function() {

        Route::get('/health', 'HealthController@check');   // [Log]

        Route::middleware(AuthMiddleware::class)
            ->group(function() {
                // runs [Log, Auth] — logging is NOT lost by the inner group
                Route::get('/user/{id}', 'UserController@get');
            });
    });

This makes a single outer group a practical way to apply a cross-cutting middleware (like request logging) to every route it wraps.

Logging

ROSA Router ships with a LogRequestMiddleware that records each incoming request (endpoint, method, params, remote address, user agent). Logging works in two independent layers:

Logging is built on Monolog. File/stream logging is included out of the box; for any other destination (a database, Slack, syslog, Elasticsearch, …) you push your own Monolog handler through the seam described below — the router keeps zero persistence or schema opinions.

  1. Trigger — bind the middleware. Like any middleware, it only runs on routes you attach it to. Logging is opt-in, never automatic:

    use Rockberpro\RosaRouter\Middleware\LogRequestMiddleware;
    
    Route::prefix('v1')
        ->middleware(LogRequestMiddleware::class)
        ->group(function() {
            Route::get('/hello', 'HelloWorldController@hello');
        });

    Because middleware accumulates through nesting, wrapping all your routes in one outer group is the simplest way to log everything — inner groups can still add their own middleware (e.g. auth) without losing the logging:

    Route::middleware(LogRequestMiddleware::class)->group(function() {
        require 'routes/api.php'; // every route inside is logged
    });
  2. Destination — pick where logs go.

    • Built in: set API_LOGS=true to write to the info log file (logs/info.log) — see Configuration.
    • Anything else: pass your own Monolog handler(s) to Bootstrap::setup(). They are attached alongside the built-in file handler, so you can enable either, both, or several at once:
    use Monolog\Handler\SlackWebhookHandler;
    use Monolog\Logger;
    use Rockberpro\RosaRouter\Bootstrap;
    
    Bootstrap::setup(
        '.env',
        'logs/info.log',
        'logs/error.log',
        // request/info handlers — e.g. mirror requests into your own DB schema,
        // Slack, syslog, Elasticsearch, …
        infoHandlers: [new SlackWebhookHandler($webhookUrl, level: Logger::INFO)],
    );

    Because it accepts any Monolog\Handler\HandlerInterface, you get Monolog's whole ecosystem — including database logging — without the library baking in a schema. Combine the middleware with others as usual:

    Route::middleware([AuthMiddleware::class, LogRequestMiddleware::class])
        ->get('/user/{id}', [UserController::class, 'get']);

No silent failures. If you bind LogRequestMiddleware to a route but leave API_LOGS disabled and supply no handler, the request has nowhere to be logged — a contradiction — and the router throws a LogHandlerException instead of quietly dropping the log. Either enable a destination, or remove the middleware from that route. A missing/undefined env variable likewise throws, so misconfiguration always surfaces loudly.

Controllers

A controller extends the base Controller class. Each action receives the Request and returns a Response — use the response() helper as a shortcut:

namespace App\Controllers;

use Rockberpro\RosaRouter\Controllers\Controller;
use Rockberpro\RosaRouter\Core\Request;
use Rockberpro\RosaRouter\Core\Response;

class UserController extends Controller
{
    public function get(Request $request): Response
    {
        $id = $request->get('id');

        // ... fetch the user from your data source

        if (!$id) {
            return $this->response(['message' => 'User not found'], Response::NOT_FOUND);
        }

        return $this->response(['id' => $id, 'name' => 'Jane Doe'], Response::OK);
    }
}

Bind it to a route by class + method, or group several actions under the same controller:

// Explicit method binding
Route::get('/user/{id}', [UserController::class, 'get']);

// Group actions under one controller
Route::controller(UserController::class)->group(function() {
    Route::get('/user/{id}', 'get');
    Route::post('/user', 'post');
});

Authentication

ROSA Router does not bundle an authentication policy — you own it. What it ships is a Jwt utility for issuing and verifying tokens, plus the middleware mechanism to enforce it. Compose them in a middleware you write, so the rules — claims, headers, token store — stay yours.

Configure signing via JWT_ISSUER / JWT_SUBJECT / JWT_SECRET in your environment, then issue tokens:

use Rockberpro\RosaRouter\Jwt;

$access  = Jwt::getAccessToken();            // short-lived access token
$refresh = Jwt::getRefreshToken($audience);  // long-lived refresh token

Verify them in your own middleware. Jwt::validate() throws JwtException on an invalid, expired, or wrong-type token:

namespace App\Middleware;

use Closure;
use Rockberpro\RosaRouter\Middleware\MiddlewareInterface;
use Rockberpro\RosaRouter\Core\Request;
use Rockberpro\RosaRouter\Core\Response;
use Rockberpro\RosaRouter\Core\Server;
use Rockberpro\RosaRouter\Jwt;

class AuthMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, Closure $next): Response
    {
        Jwt::validate(Server::authorization(), 'access'); // throws on failure
        return $next($request);
    }
}

Attach it to routes as shown under Middleware, and send the token on protected requests via the Authorization: Bearer <token> header.

Testing

The test suite runs on PHPUnit:

composer install
vendor/bin/phpunit tests

License

ROSA Router is open-source software licensed under the MIT License.

Made with ❤️ by rockberpro