Skip to content

SoDaHo/php-router

Repository files navigation

php-router

Lightweight PHP Router for REST APIs and SPAs. Standardized JSON responses, middleware, caching.

Why This Library?

What it does:

  • PSR-7/PSR-15 compliant routing with typed route parameters and auto-casting
  • Standardized JSON response format (pluggable via ResponderInterface)
  • Route caching with HMAC integrity verification
  • Middleware, route groups, named routes, URL generation

What it deliberately does not:

  • No optional route segments, no inline regex, no route priority system
  • No CORS, CSRF, authentication, or rate limiting (use middleware)
  • No async/Swoole runtime (use handle() + your own emitter)
  • Not optimized for >500 dynamic routes (O(n) matching)

Installation

composer require sodaho/php-router

Quick Start

use Sodaho\Router\Router;
use Sodaho\Router\Response;

$router = Router::create();
$router->loadRoutes(__DIR__ . '/routes.php');
$router->run();

routes.php:

use Sodaho\Router\RouteCollector;

return function (RouteCollector $r) {
    $r->get('/users', [UserController::class, 'index']);
    $r->get('/users/{id:int}', [UserController::class, 'show']);
    $r->post('/users', [UserController::class, 'store']);
};

Controller:

use Sodaho\Router\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

class UserController
{
    public function show(ServerRequestInterface $request, int $id): ResponseInterface
    {
        $user = ['id' => $id, 'name' => 'John'];
        return Response::success($user);
    }
}

HTTP Methods

$r->get('/users', $handler);
$r->post('/users', $handler);
$r->put('/users/{id}', $handler);
$r->patch('/users/{id}', $handler);
$r->delete('/users/{id}', $handler);
$r->options('/users', $handler);
$r->head('/users', $handler);

// Multiple methods
$r->match(['GET', 'POST'], '/search', $handler);

// All methods
$r->any('/webhook', $handler);

Route Parameters

// Basic parameter
$r->get('/users/{id}', $handler);

// With type constraint (validates + casts automatically)
$r->get('/users/{id:int}', $handler);        // Integer
$r->get('/price/{value:float}', $handler);   // Decimal
$r->get('/active/{flag:bool}', $handler);    // Boolean (true/false/1/0)

// Pattern constraints
$r->get('/posts/{slug:slug}', $handler);     // a-z, 0-9, hyphens
$r->get('/users/{uuid:uuid}', $handler);     // UUID format
$r->get('/files/{path:any}', $handler);      // Anything (including slashes)
$r->get('/codes/{code:alphanum}', $handler); // Alphanumeric

Available Patterns

Shorthand Regex Example
int -?\d+ {id:int} → 123, -5
float -?\d+(?:\.\d+)? {price:float} → 19.99
bool true|false|0|1 (case-insensitive) {active:bool} → true, TRUE
alpha [a-zA-Z]+ {name:alpha} → abc
alphanum [a-zA-Z0-9]+ {code:alphanum} → abc123
slug [a-z0-9-]+ {slug:slug} → my-post
uuid [0-9a-fA-F]{8}-... {id:uuid} → 550e8400-...
ulid [0-9A-Za-z]{26} {id:ulid} → 01ARZ3NDEKTSV4RRFFQ69G5FAV
any .* {path:any} → anything/here

Custom Patterns

$r->addPattern('date', '\d{4}-\d{2}-\d{2}');
$r->get('/events/{date:date}', $handler);  // 2024-12-06

Accessing Parameters

// Option A: Named arguments (recommended)
public function show(ServerRequestInterface $request, int $id): ResponseInterface
{
    // $id is already typed and validated
}

// Option B: From request attributes
public function show(ServerRequestInterface $request): ResponseInterface
{
    $id = $request->getAttribute('id');
}

Route Groups

$r->group('/api', function (RouteCollector $r) {
    $r->group('/v1', function (RouteCollector $r) {
        $r->get('/users', [UserController::class, 'index']);
    });
});
// → /api/v1/users

Middleware

use Psr\Http\Server\MiddlewareInterface;

// Per route
$r->get('/dashboard', [DashboardController::class, 'index'])
    ->middleware(AuthMiddleware::class);

// Multiple middleware
$r->get('/admin', [AdminController::class, 'index'])
    ->middleware([AuthMiddleware::class, AdminMiddleware::class]);

// Middleware group
$r->middlewareGroup([AuthMiddleware::class, LogMiddleware::class], function ($r) {
    $r->get('/profile', [ProfileController::class, 'show']);
    $r->put('/profile', [ProfileController::class, 'update']);
});

Route parameters are available in middleware:

class OwnershipMiddleware implements MiddlewareInterface
{
    public function process($request, $handler): ResponseInterface
    {
        $orderId = $request->getAttribute('id');  // Available!
        // ... ownership check
        return $handler->handle($request);
    }
}

Named Routes & URL Generation

$r->get('/users/{id}', [UserController::class, 'show'])
    ->name('user.show');

// Generate URL
$url = $router->url('user.show', ['id' => 5]);
// → /users/5

// Absolute URL (requires APP_URL env variable)
$url = $router->absoluteUrl('user.show', ['id' => 5]);
// → https://example.com/users/5

Redirect Routes

$r->redirect('/old-url', '/new-url');           // 302 Temporary
$r->redirect('/old-url', '/new-url', 301);      // 301 Permanent
$r->redirect('/users/{id}/profile', '/profile/{id}');  // With parameters

Response Helpers

Success Responses

Response::success($data);                              // 200
Response::success($data, 'Created successfully');      // 200 with message
Response::created($data);                              // 201
Response::created($data, 'User created', '/users/5');  // 201 with Location header
Response::accepted($data);                             // 202
Response::noContent();                                 // 204
Response::paginated($items, $total, $page, $perPage);  // 200 with pagination meta

Error Responses

Response::error('Something went wrong', 400);              // Generic error
Response::error('Invalid input', 400, 'INVALID_INPUT');    // With error code
Response::notFound('User', 123);                           // 404 "User with identifier 123 not found"
Response::notFound();                                      // 404 "Resource not found"
Response::unauthorized();                                  // 401
Response::unauthorized('Token expired');                   // 401 with message
Response::forbidden();                                     // 403
Response::validationError(['email' => 'Invalid format']);  // 422
Response::methodNotAllowed(['GET', 'POST']);               // 405
Response::tooManyRequests(60);                             // 429 with Retry-After
Response::serverError();                                   // 500

Other Responses

Response::html($content);                                // text/html
Response::html($content, 404);                           // text/html with status
Response::text($content);                                // text/plain
Response::redirect('/new-url');                          // 302
Response::redirect('/new-url', 301);                     // 301
Response::download($content, 'file.pdf');                // Attachment
Response::download($content, 'file.pdf', 'application/pdf');

JSON Structure

Success:

{
    "success": true,
    "data": { ... },
    "message": "Optional message",
    "meta": { "pagination": { ... } }
}

Error:

{
    "success": false,
    "message": "User-friendly message",
    "error": {
        "message": "Technical message",
        "code": "ERROR_CODE",
        "details": { ... }
    }
}

Configuration

Via Config Array

$router = Router::create([
    'debug' => true,
    'basePath' => '/api',
    'baseUrl' => 'https://api.example.com',
    'trailingSlash' => 'ignore',
]);

Via Environment Variables

// .env
APP_DEBUG=true
APP_ENV=development
APP_URL=https://api.example.com
ROUTER_BASE_PATH=/api
ROUTER_TRAILING_SLASH=ignore
ROUTER_CACHE_FILE=/var/cache/routes.php
ROUTER_CACHE_KEY=your-secret-key

Via Fluent API

$router = Router::create()
    ->setDebug(true)
    ->setBasePath('/api')
    ->enableCache(__DIR__ . '/cache/routes.php');

Options

Config Key ENV Variable Default Description
debug APP_DEBUG false Enable debug mode (detailed errors)
- APP_ENV production If dev/local/development → debug=true
basePath ROUTER_BASE_PATH '' URL prefix for all routes
baseUrl APP_URL null Base URL for absoluteUrl()
trailingSlash ROUTER_TRAILING_SLASH 'strict' 'strict' or 'ignore'
cacheFile ROUTER_CACHE_FILE null Path to cache file
cacheSignature ROUTER_CACHE_KEY null HMAC key for cache integrity

Caching

// Enable cache with optional HMAC signature
$router = Router::create()
    ->enableCache(__DIR__ . '/cache/routes.php', 'your-secret-key')
    ->loadRoutes(__DIR__ . '/routes.php');

$router->run();

Note: Closures cannot be cached. Use [Controller::class, 'method'] syntax.

Hooks (Logging)

// Log successful dispatches
$router->on('dispatch', function (array $data) {
    // $data: method, path, route, handler, params, duration
    $logger->info("Route matched", $data);
});

// Log 404 errors
$router->on('notFound', function (array $data) {
    // $data: method, path
    $logger->warning("404", $data);
});

// Log 405 errors
$router->on('methodNotAllowed', function (array $data) {
    // $data: method, path, allowed_methods
    $logger->warning("405", $data);
});

// Log exceptions
$router->on('error', function (array $data) {
    // $data: method, path, exception
    $logger->error("Error", $data);
});

Note: Hook exceptions are caught and logged to stderr. They never affect the response.

PSR-15 Compatibility

// run() for simple apps
$router->run();

// handle() for PSR-15 integration
$request = $serverRequestFactory->fromGlobals();
$response = $router->handle($request);  // Returns ResponseInterface

// Emit response yourself
(new SapiEmitter())->emit($response);

Dependency Injection

use Psr\Container\ContainerInterface;

$router = Router::create()
    ->setContainer($container)  // Any PSR-11 container
    ->loadRoutes(__DIR__ . '/routes.php');

// Controllers are resolved via container if available
// Otherwise instantiated directly

Exceptions

All exceptions extend RouterException:

use Sodaho\Router\Exception\RouterException;
use Sodaho\Router\Exception\NotFoundException;
use Sodaho\Router\Exception\MethodNotAllowedException;
use Sodaho\Router\Exception\RouteNotFoundException;
use Sodaho\Router\Exception\DuplicateRouteException;
use Sodaho\Router\Exception\CacheException;

try {
    $router->run();
} catch (RouterException $e) {
    // Catches all router exceptions
    echo $e->getMessage();
    echo $e->getDebugMessage();  // Additional debug info
}
Exception When
NotFoundException Available for application use (router returns 404 response directly)
MethodNotAllowedException Available for application use (router returns 405 response directly)
RouteNotFoundException Named route doesn't exist (URL generation)
DuplicateRouteException Same method+pattern registered twice
CacheException Cache read/write/signature failure

Trailing Slash Handling

// Default: strict (exact match)
$r->get('/users', $handler);   // Only matches /users
$r->get('/users/', $handler);  // Only matches /users/

// Ignore mode: /users matches both /users and /users/
$router = Router::create(['trailingSlash' => 'ignore']);

SPA Catch-All (Vue/React)

// API routes first
$r->group('/api', function ($r) {
    $r->get('/users', [UserController::class, 'index']);
});

// Catch-all for Vue Router (history mode)
$r->get('/{any:any}', [PageController::class, 'index']);
class PageController
{
    public function index($request): ResponseInterface
    {
        return Response::html(file_get_contents('public/index.html'));
    }
}

Quick Boot

// One-liner for simple apps
Router::boot(['debug' => true], __DIR__ . '/routes.php');

Webserver Configuration

Apache (.htaccess)

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

nginx

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

Custom Response Formats

The router uses JsonResponder by default. You can swap it for RFC 7807 or custom formats:

use Sodaho\Router\Response;
use Sodaho\Router\Service\RfcResponder;

// RFC 7807 Problem Details format
Response::setResponder(new RfcResponder('https://api.example.com/errors'));

// Error responses now use RFC 7807:
// {
//   "type": "https://api.example.com/errors/not-found",
//   "title": "User not found",
//   "status": 404,
//   "detail": "User with ID 123 not found"
// }

Create your own responder:

use Sodaho\Router\Contract\ResponderInterface;

class XmlResponder implements ResponderInterface
{
    public function formatSuccess(mixed $data, ?string $message = null, ?array $meta = null): array
    {
        // Return array that will be converted to XML
    }

    public function formatError(string $message, ?string $code = null, ?array $details = null): array
    {
        // Return array for error responses
    }

    public function getContentType(): string
    {
        return 'application/xml';  // Used for 4xx/5xx responses
    }

    public function getSuccessContentType(): string
    {
        return 'application/xml';  // Used for 2xx responses
    }
}

Reset in tests:

protected function tearDown(): void
{
    Response::reset(); // Restores default JsonResponder
}

Limitations

What this router does NOT support:

Feature Reason
Optional segments [/suffix] Complexity vs. benefit. Define two routes instead.
Regex in route patterns Use predefined patterns or addPattern().
Route priority/ordering Routes match in definition order. Define specific routes first.
Async/Swoole out-of-box Use handle() method, not run(). Emit response yourself.
>500 dynamic routes efficiently O(n) matching. Consider splitting into microservices.

Workarounds:

// Instead of optional segments:
$r->get('/users', $handler);
$r->get('/users/{id}', $handler);

// Instead of inline regex:
$r->addPattern('date', '\d{4}-\d{2}-\d{2}');
$r->get('/events/{date:date}', $handler);

Performance

Route Caching

Always enable caching in production:

$router = Router::create()
    ->enableCache(__DIR__ . '/../var/cache/routes.php', $_ENV['APP_KEY'])
    ->loadRoutes(__DIR__ . '/routes.php');
Mode 50 Routes 200 Routes
No cache ~2-5ms ~5-15ms
With cache ~0.1ms ~0.2ms

Route Matching Complexity

Route Type Complexity Example
Static O(1) /users, /api/health
Dynamic O(n) /users/{id}, /posts/{slug}

Tips:

  • Static routes are instant (hash lookup)
  • Dynamic routes loop through candidates
  • Define most-used routes first
  • Keep dynamic routes under 500 for best performance

Memory

  • Route cache uses OPcache (no memory parsing)
  • ~1KB per route in memory
  • 100 routes ≈ 100KB memory footprint

Security Best Practices

Open Redirect Prevention

Never redirect to user input without validation:

// DANGEROUS - Open Redirect vulnerability!
$r->get('/goto', function ($request) {
    $url = $request->getQueryParams()['url'];
    return Response::redirect($url);  // Attacker: ?url=https://evil.com
});

// SAFE - Whitelist or validate
$r->get('/goto', function ($request) {
    $url = $request->getQueryParams()['url'] ?? '/';
    $allowed = ['/', '/dashboard', '/profile'];

    if (!in_array($url, $allowed, true)) {
        return Response::error('Invalid redirect', 400);
    }

    return Response::redirect($url);
});

CSRF Protection

This router does not include CSRF protection. For state-changing operations:

// Option 1: Use a CSRF middleware
$r->middlewareGroup([CsrfMiddleware::class], function ($r) {
    $r->post('/users', [UserController::class, 'store']);
    $r->delete('/users/{id}', [UserController::class, 'destroy']);
});

// Option 2: For SPAs - use SameSite cookies + custom header
// Frontend sends: X-Requested-With: XMLHttpRequest
// Backend validates header presence

Input Validation

Route parameter types ({id:int}) validate format, not business logic:

// {id:int} ensures $id is an integer, but NOT that:
// - The user exists
// - The current user can access it
// - The ID is within valid range

public function show(ServerRequestInterface $request, int $id): ResponseInterface
{
    // Always validate business logic!
    $user = $this->userRepository->find($id);

    if ($user === null) {
        return Response::notFound('User', $id);
    }

    if (!$this->canAccess($request, $user)) {
        return Response::forbidden();
    }

    return Response::success($user);
}

Debug Mode

Never enable debug mode in production:

// Debug mode exposes:
// - Full exception messages
// - Stack traces
// - File paths
// - Internal error details

// .env.production
APP_DEBUG=false
APP_ENV=production

Testing

composer install
vendor/bin/phpunit

Requirements

  • PHP ^8.2
  • PSR-7 HTTP Message (nyholm/psr7)
  • PSR-15 HTTP Handler/Middleware

Acknowledgments

Parts of this project (refactoring, documentation, code review) were developed with AI assistance (Claude).

License

MIT

About

Lightweight PHP Router for REST APIs and SPAs. Standardized JSON responses, middleware, caching.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages