Router
BetterRoute\Router\Router is the contract-first route builder.
When to use
- You need fully custom handlers (not CRUD resource presets)
- You want per-route meta for OpenAPI
- You need middleware at global/group/route scope
Minimal example
use BetterRoute\Router\Router;
add_action('rest_api_init', function (): void {
$router = Router::make('better-route', 'v1')
->middleware([
static function ($ctx, callable $next): mixed {
return $next($ctx);
},
]);
$router->group('/admin', function (Router $r): void {
$r->get('/health', fn (): array => ['ok' => true])
->meta(['operationId' => 'adminHealth', 'tags' => ['Admin']])
->permission(static fn (): bool => current_user_can('manage_options'));
});
$router->register();
});
Public API
Router::make(string $vendor, string $version): Routermiddleware(array $middlewares): selfmiddlewareFactory(callable $factory): selfgroup(string $prefix, callable $callback): selfget/post/put/patch/delete/options(string $uri, mixed $handler): RouteBuilderroutes(): arraybaseNamespace(): stringcontracts(bool $openApiOnly = false): arrayregister(?DispatcherInterface $dispatcher = null): void
Handler signatures
ArgumentResolver supports:
fn (): mixedfn (RequestContext $ctx): mixedfn ($request): mixedfn (RequestContext $ctx, $request): mixed[ControllerClass::class, 'method'](instantiated internally)
Route intent (v0.4.0)
GET routes registered without an explicit permission() callback are public by default. Write methods (POST, PUT, PATCH, DELETE) are deny-by-default at the WordPress permission layer — they fail with 403 until you make intent explicit:
->permission(callable)— supply a WP permission callback (e.g. capability checks).->protectedByMiddleware(string|array|null $security = null)— let the request reach the better-route middleware pipeline so an auth middleware (JwtAuthMiddleware,BearerTokenAuthMiddleware, etc.) can authenticate or short-circuit. Optional argument sets the OpenAPIsecurityfor the operation.->publicRoute()— mark the route as intentionally public; also clears the operation-level OpenAPIsecurityso it overrides any global scheme.
$r->post('/articles', $handler)
->permission(static fn () => current_user_can('edit_posts'));
$r->post('/secure/articles', $handler)
->protectedByMiddleware('bearerAuth');
$r->post('/webhooks/intake', $handler)
->publicRoute();
Resource-backed endpoints already enforce their own ResourcePolicy and are unaffected.
Common mistakes
- Class-string middleware requiring constructor args without
middlewareFactory - Missing explicit route intent on a write method (returns
403since v0.4.0) - Registering outside
rest_api_initwithout custom dispatcher
Validation checklist
- middleware order is
global -> group -> route - generated route URIs are normalized (
/xnot//x/) contracts(true)excludesopenapi.include=false- every write route declares intent via
permission(),protectedByMiddleware(), orpublicRoute()
v0.6.0 behavior changes
Router::dispatch()now stores normalized route metadata underRequestContext::$attributes['routeMeta']. This is what powers route-level normalizer selection — for example, the OAuth error format opt-in works becauseResponseNormalizerreadsrouteMeta.error_formatfrom the context. Existing handlers ignore the new attribute; no migration needed.
v0.5.0 behavior changes
Router::options(string $uri, mixed $handler): RouteBuilderregisters explicitOPTIONSroutes for CORS preflight.OPTIONSpermissions default to public so the browser preflight reaches the better-route pipeline (whereCorsMiddlewareshort-circuits with204).- See Public-Client APIs for the recommended pipeline order.
v0.4.0 behavior changes
- Write methods (
POST/PUT/PATCH/DELETE) registered on the rawRouterwithout an explicit permission callback now deny by default.GETstays public by default. - New
RouteBuilder::publicRoute()andRouteBuilder::protectedByMiddleware()helpers make route intent explicit at the call site. - Per-operation
security: []now overridesglobalSecurityin the OpenAPI exporter (see OpenAPI Overview).
v0.3.0 behavior changes
- Inbound
X-Request-IDis accepted only if it matches^[A-Za-z0-9._:-]{1,128}$. Anything else is replaced with a generatedreq_<hex>id. - For Resource and Woo handlers,
idis read from URL route params first; query/bodyidis consulted only when the URL does not provide one.