puff/routing

Fast, Fiber-safe HTTP route registration and matching for Puff

Maintainers

Package info

github.com/php-puff/routing

pkg:composer/puff/routing

Transparency log

Statistics

Installs: 0

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-27 14:58 UTC

This package is auto-updated.

Last update: 2026-08-27 15:00:34 UTC


README

Fast, Fiber-safe HTTP route registration and matching for Puff.

The package compiles mutable route declarations once at application startup. Exact paths are stored in a hash index, dynamic paths are prebuilt as regular expressions, and every request receives an immutable RouteMatched value so route parameters are never shared between Fibers.

Installation

composer require puff/routing

puff/http-server registers the router and loads route files automatically. A route file can use the global Router facade:

<?php

Router::get('/', 'Home@index')->id('home');
Router::post('/users', 'User@create')->id('users.create');
Router::any('/health', static fn (): array => ['status' => 'ok']);

Supported registration methods are any, get, post, put, patch, delete, head, and options. A HEAD request falls back to its matching GET route when no explicit HEAD route exists.

Route parameters

Parameters use (name:type) syntax:

Router::get('/users/(id:int)', 'User@show');
Router::get('/posts/(slug:str)', 'Post@show');
Router::get('/files/(path:*)', 'File@show');
Router::get('/colors/(value:hex)', 'Color@show');
Router::get('/tokens/(value:hash)', 'Token@show');
Router::get('/events/(id:uuid)', 'Event@show');
Router::get('/prices/(value:num)', 'Price@show');
Router::get('/echo/(value:any)', 'Echo@show');

Built-in parameter types:

Type Pattern Notes
* .+ Any non-empty path content
str [\w-]+ Letters, numbers, underscore and hyphen
int (?:0|[1-9][0-9]*) Unsigned integer including zero
num -?(?:[0-9]+(?:\.[0-9]+)?) Integer or decimal value
any `[\w!@$^&+-= ]+`
hex [A-Fa-f0-9]+ Hexadecimal characters
hash [A-Za-z0-9]+ Alphanumeric characters
uuid UUID-shaped hexadecimal value Standard 8-4-4-4-12 shape

Register an application-specific pattern before declaring routes that use it:

Router::regex(':slug', ':[a-z0-9]+(?:-[a-z0-9]+)*');
Router::get('/articles/(slug:slug)', 'Article@show');

Matched parameters are injected into controller arguments by name:

final class User
{
    public function show(string $id): array
    {
        return ['id' => $id];
    }
}

Route options

Pass an option array when a route needs a pipeline or metadata:

Router::get('/account', [
    'call' => 'Account@index',
    'pipeline' => [Authenticate::class, AuthorizeAccount::class],
    'locale' => 'en',
    'menu' => true,
])->id('account');

Route pipelines run after the global HTTP pipeline. Duplicate pipeline class names are removed while preserving their first occurrence.

Groups

Groups share a path prefix, controller namespace, pipeline and optional metadata:

Router::group([
    'id' => 'api',
    'prefix' => '/api',
    'namespace' => 'App\\Controller\\Api',
    'pipeline' => [ApiHeaders::class],
    'locale' => 'en',
], static function (): void {
    Router::get('/status', 'Status@index')->id('api.status');

    Router::group([
        'id' => 'api.private',
        'prefix' => 'private',
        'pipeline' => [Authenticate::class],
    ], static function (): void {
        Router::get('/profile', 'Profile@index')->id('api.profile');
    });
});

Nested prefixes, namespaces and pipelines inherit from their parent. A nested prefix beginning with /, or a namespace beginning with \, replaces the corresponding parent value.

Configure a global pipeline in config/http.server.php:

'pipeline' => [
    RequestId::class,
    AccessLog::class,
],

The execution order is global pipeline first, followed by inherited group pipelines and then route pipelines.

Named and current routes

$current = route();
$route = route('api.profile');

$id = $current->definition()->id;
$parameters = $current->args();

During request dispatch, route() returns the request-local RouteMatched. Looking up a name returns its immutable RouteDefinition.

Standalone matching

The compiler and matcher can be used without the HTTP server dispatcher:

use Puff\Http\Request;
use Puff\Routing\RouteCompiler;
use Puff\Routing\RouteMatcher;
use Puff\Routing\Router;

$router = new Router(new Request());
$router->get('/users/(id:int)', 'User@show')->id('users.show');

$routes = (new RouteCompiler())->compile($router->routes());
$matched = (new RouteMatcher($routes))->match('GET', '/users/42');

echo $matched->definition()->id; // users.show
print_r($matched->args());       // ['id' => '42']

RouteMatcher::match() throws NotFoundException with HTTP status 404, or MethodNotAllowedException with status 405.

Performance characteristics

  • Route files are loaded and compiled once by puff/http-server, not once per request.
  • Exact route lookup is average O(1).
  • Dynamic routes are indexed by their longest static path prefix. Only patterns in the matching prefix buckets are tested; overlapping patterns within the same bucket remain declaration ordered.
  • Exact routes always take priority over dynamic routes.
  • Compiled definitions and matched results are immutable; only the request-scoped router stores the current match.

For large applications, prefer exact routes where practical and avoid many overlapping catch-all patterns.

Development

composer validate --strict
composer test
composer analyse

Puff Routing requires PHP 8.2 or later and is released under the MIT License.