承接 hd3r/php-router 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

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

hd3r/php-router

最新稳定版本:v1.1.1

Composer 安装命令:

composer require hd3r/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.

Installation

composer require hd3r/php-router

Quick Start

use Hd3r\Router\Router;
use Hd3r\Router\Response;

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

routes.php:

use Hd3r\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 Hd3r\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 {active:bool} → 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-f]{8}-... {id:uuid} → 550e8400-...
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 Hd3r\Router\Exception\RouterException;
use Hd3r\Router\Exception\NotFoundException;
use Hd3r\Router\Exception\MethodNotAllowedException;
use Hd3r\Router\Exception\RouteNotFoundException;
use Hd3r\Router\Exception\DuplicateRouteException;
use Hd3r\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 Route not found (404)
MethodNotAllowedException Wrong HTTP method (405)
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 Hd3r\Router\Response;
use Hd3r\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 Hd3r\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.1
  • PSR-7 HTTP Message (nyholm/psr7)
  • PSR-15 HTTP Handler/Middleware

License

MIT

统计信息

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

GitHub 信息

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

其他信息

  • 授权协议: MIT
  • 更新时间: 2025-12-07