Skip to main content

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): Router
  • middleware(array $middlewares): self
  • middlewareFactory(callable $factory): self
  • group(string $prefix, callable $callback): self
  • get/post/put/patch/delete/options(string $uri, mixed $handler): RouteBuilder
  • routes(): array
  • baseNamespace(): string
  • contracts(bool $openApiOnly = false): array
  • register(?DispatcherInterface $dispatcher = null): void

Handler signatures

ArgumentResolver supports:

  • fn (): mixed
  • fn (RequestContext $ctx): mixed
  • fn ($request): mixed
  • fn (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 OpenAPI security for the operation.
  • ->publicRoute() — mark the route as intentionally public; also clears the operation-level OpenAPI security so 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 403 since v0.4.0)
  • Registering outside rest_api_init without custom dispatcher

Validation checklist

  • middleware order is global -> group -> route
  • generated route URIs are normalized (/x not //x/)
  • contracts(true) excludes openapi.include=false
  • every write route declares intent via permission(), protectedByMiddleware(), or publicRoute()

v0.6.0 behavior changes

  • Router::dispatch() now stores normalized route metadata under RequestContext::$attributes['routeMeta']. This is what powers route-level normalizer selection — for example, the OAuth error format opt-in works because ResponseNormalizer reads routeMeta.error_format from the context. Existing handlers ignore the new attribute; no migration needed.

v0.5.0 behavior changes

  • Router::options(string $uri, mixed $handler): RouteBuilder registers explicit OPTIONS routes for CORS preflight. OPTIONS permissions default to public so the browser preflight reaches the better-route pipeline (where CorsMiddleware short-circuits with 204).
  • See Public-Client APIs for the recommended pipeline order.

v0.4.0 behavior changes

  • Write methods (POST/PUT/PATCH/DELETE) registered on the raw Router without an explicit permission callback now deny by default. GET stays public by default.
  • New RouteBuilder::publicRoute() and RouteBuilder::protectedByMiddleware() helpers make route intent explicit at the call site.
  • Per-operation security: [] now overrides globalSecurity in the OpenAPI exporter (see OpenAPI Overview).

v0.3.0 behavior changes

  • Inbound X-Request-ID is accepted only if it matches ^[A-Za-z0-9._:-]{1,128}$. Anything else is replaced with a generated req_<hex> id.
  • For Resource and Woo handlers, id is read from URL route params first; query/body id is consulted only when the URL does not provide one.