rockberpro / rosa-router
Smart REST router for PHP
Requires
- php: >=8.4
- ext-json: *
- ext-pdo: *
- monolog/monolog: 3.9
- react/http: ^1.11
- symfony/dotenv: 8.0
- symfony/http-foundation: 8.1
Requires (Dev)
- phpunit/phpunit: ^9.6
- symfony/var-dumper: ^5.4
This package is not auto-updated.
Last update: 2026-08-02 03:24:43 UTC
README
A smart, lightweight and fast REST router for PHP.
Table of Contents
- Overview
- Why ROSA Router?
- Features
- Requirements
- Installation
- Configuration
- Quick Start
- Usage
- Authentication
- Logging
- Testing
- License
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 support —
GET,POST,PUT,PATCHandDELETE. - 🧩 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.
-
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 });
-
Destination — pick where logs go.
- Built in: set
API_LOGS=trueto 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']);
- Built in: set
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
