sodaho/php-router 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

sodaho/php-router

Composer 安装命令:

composer require sodaho/php-router

包简介

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

README 文档

README

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

sodaho/php-router 适用场景与选型建议

sodaho/php-router 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 143 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 03 月 24 日, 在 PHP 生态内属于活跃度较高的组件。

它主要适用于以下技术方向: 「php」 「json」 「rest」 「api」 「router」 「middleware」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。

我们在过去多个企业项目中使用过 sodaho/php-router 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。

围绕 sodaho/php-router 我们能提供哪些服务?
定制开发 / 二次开发

基于 sodaho/php-router 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。

BUG 修复 & 性能优化

线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。

项目外包 & 长期维护

承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。

yvsm@zunyunkeji.com QQ:316430983 微信:yvsm316 西安尊云信息科技 · 专注 PHP / Go / 分布式系统研发

统计信息

  • 总下载量: 143
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 19
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-03-24