PHPackages                             sodaho/php-router - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. sodaho/php-router

ActiveLibrary[HTTP &amp; Networking](/categories/http)

sodaho/php-router
=================

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

v1.0.0(3mo ago)07MITPHPPHP ^8.2CI passing

Since Mar 19Pushed 3w agoCompare

[ Source](https://github.com/SoDaHo/php-router)[ Packagist](https://packagist.org/packages/sodaho/php-router)[ RSS](/packages/sodaho-php-router/feed)WikiDiscussions main Synced 3w ago

READMEChangelogDependencies (11)Versions (2)Used By (0)

php-router
==========

[](#php-router)

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

Why This Library?
-----------------

[](#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 &gt;500 dynamic routes (O(n) matching)

Installation
------------

[](#installation)

```
composer require sodaho/php-router
```

Quick Start
-----------

[](#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
------------

[](#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
----------------

[](#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

[](#available-patterns)

ShorthandRegexExample`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

[](#custom-patterns)

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

### Accessing Parameters

[](#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
------------

[](#route-groups)

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

Middleware
----------

[](#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 &amp; URL Generation
---------------------------------

[](#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
---------------

[](#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
----------------

[](#response-helpers)

### Success Responses

[](#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

[](#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

[](#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

[](#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
-------------

[](#configuration)

### Via Config Array

[](#via-config-array)

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

### Via Environment Variables

[](#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

[](#via-fluent-api)

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

### Options

[](#options)

Config KeyENV VariableDefaultDescription`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 integrityCaching
-------

[](#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)
---------------

[](#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
--------------------

[](#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
--------------------

[](#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
----------

[](#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
}
```

ExceptionWhen`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 failureTrailing Slash Handling
-----------------------

[](#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)
-------------------------

[](#spa-catch-all-vuereact)

```
// 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
----------

[](#quick-boot)

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

Webserver Configuration
-----------------------

[](#webserver-configuration)

### Apache (.htaccess)

[](#apache-htaccess)

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

### nginx

[](#nginx)

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

Custom Response Formats
-----------------------

[](#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
-----------

[](#limitations)

**What this router does NOT support:**

FeatureReasonOptional segments `[/suffix]`Complexity vs. benefit. Define two routes instead.Regex in route patternsUse predefined patterns or `addPattern()`.Route priority/orderingRoutes match in definition order. Define specific routes first.Async/Swoole out-of-boxUse `handle()` method, not `run()`. Emit response yourself.&gt;500 dynamic routes efficientlyO(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
-----------

[](#performance)

### Route Caching

[](#route-caching)

**Always enable caching in production:**

```
$router = Router::create()
    ->enableCache(__DIR__ . '/../var/cache/routes.php', $_ENV['APP_KEY'])
    ->loadRoutes(__DIR__ . '/routes.php');
```

Mode50 Routes200 RoutesNo cache~2-5ms~5-15msWith cache~0.1ms~0.2ms### Route Matching Complexity

[](#route-matching-complexity)

Route TypeComplexityExampleStaticO(1)`/users`, `/api/health`DynamicO(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

[](#memory)

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

Security Best Practices
-----------------------

[](#security-best-practices)

### Open Redirect Prevention

[](#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

[](#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

[](#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

[](#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
-------

[](#testing)

```
composer install
vendor/bin/phpunit
```

Requirements
------------

[](#requirements)

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

Acknowledgments
---------------

[](#acknowledgments)

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

License
-------

[](#license)

MIT

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance89

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity46

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

96d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/4407ed68227862f26dfbd1fff5c4d117c89ba024bf20202280d00d8199036cfc?d=identicon)[SoDaHo](/maintainers/SoDaHo)

---

Top Contributors

[![SoDaHo](https://avatars.githubusercontent.com/u/73506118?v=4)](https://github.com/SoDaHo "SoDaHo (14 commits)")

---

Tags

apijsonmiddlewarephppsr-11psr-15psr-7restrouterpsr-7phpjsonmiddlewareapiPSR-11restrouterpsr-15

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/sodaho-php-router/health.svg)

```
[![Health](https://phpackages.com/badges/sodaho-php-router/health.svg)](https://phpackages.com/packages/sodaho-php-router)
```

###  Alternatives

[cakephp/cakephp

The CakePHP framework

8.8k19.1M1.7k](/packages/cakephp-cakephp)[mezzio/mezzio

PSR-15 Middleware Microframework

3903.8M120](/packages/mezzio-mezzio)[jaxon-php/jaxon-core

Jaxon is an open source PHP library for easily creating Ajax web applications

73147.2k29](/packages/jaxon-php-jaxon-core)[tempest/framework

The PHP framework that gets out of your way.

2.2k31.1k12](/packages/tempest-framework)[sunrise/http-router

A powerful solution as the foundation of your project.

17450.9k10](/packages/sunrise-http-router)[mezzio/mezzio-authentication-oauth2

OAuth2 (server) authentication middleware for Mezzio and PSR-7 applications.

28545.4k3](/packages/mezzio-mezzio-authentication-oauth2)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
