Routing Surfaces
Reference for how middag-io/wordpress routes requests into a consumer plugin. Backs WP-001 (WordPress HTTP integration). The controller classes behind these surfaces are in Controller Bases.
Verified against middag-php-wordpress/main@bc1bc59 on 2026-11-04.
The three surfaces
Every surface is namespaced by the component name from the injected HostComponentContextInterface (WpComponentContext), never by a brand literal, so two plugins in one request never share a REST root, a menu slug or a query var. tests/Architecture/RoutingBrandLiteralTest.php fails the build if a routing source contains the literal middag.
| Surface | Class | WordPress mechanism | Namespacing | Dispatch |
|---|---|---|---|---|
| REST | Http/Routing/RestRouteRegistrar (imperative) or Http/Routing/RestRouteAttributeRegistrar (attributes) | register_rest_route | {component}/{version}, default version v1 | permission_callback + callback per route |
| Admin | Admin/AdminRouteRegistrar | add_menu_page / add_submenu_page | menu slug {component}, submenu {component}-{suffix} | framework HTTP kernel (WpHttpKernel) |
| Public | Http/Routing/PublicRouteRegistrar | add_rewrite_rule + query_vars + template_redirect | query var {component}_route | the route's own handler |
A consumer wires each surface in its own composition root. The consumer kernel (see Configuration & Identity) calls RestRouteRegistrar::register() on rest_api_init when the container binds it; the admin and public registrars are wired by the consumer.
REST
WordPress models REST as registration: one register_rest_route() call per route with a boolean permission_callback and a callback, dispatched over WP_REST_Request and answered with WP_REST_Response or WP_Error. The adapter keeps that model. Two registrars produce the same namespace, so both can coexist while controllers migrate one by one.
Imperative: RestRouteRegistrar
addController($class)collects classes;register()resolves each and, when it implementsHttp/Contract/RestControllerInterface, callsregisterRoutes($namespace)with{component}/{version}.- The controller registers its own routes, usually through
AbstractWpRestController::route()(see Controller Bases).
Declarative: RestRouteAttributeRegistrar + WpRestKernel
- Controllers declare routes with the framework
#[Route]attribute (Symfony route shape). The registrar loads them through the frameworkRouteLoaderInterfaceand registers one WordPress route per Symfony route. A route without methods defaults toGET. Http/Routing/RestPathTranslator(@internal) rewrites Symfony placeholders into WordPress named groups:/organizations/{id}with['id' => '\d+']becomes/organizations/(?P<id>\d+); a placeholder without a requirement becomes[^/]+.- Every route's
permission_callbackisWpRestKernel::authorize()and itscallbackisWpRestKernel::handle().
WpRestKernel::authorize():
OPTIONS(CORS preflight) passes.- The effective
#[Auth]is the method-level attribute, else the class-level one, elsenew Auth()— so an action with no attribute requires login (secure default). login: falsepasses.- Otherwise the injected
RequestAuthenticatorInterfaceresolves the user. AWP_Erroris returned as is; no user gives401 unauthorized. - If the
#[Auth]lists any capability, the request must come from an administrator (RequestAuthenticatorInterface::isAdmin()), otherwise403 forbidden. The individual capability strings are not checked one by one on this surface.
WpRestKernel::handle():
- Resolves the
#[Middleware]chain: class-level entries first (outermost), then method-level, each in declaration order. Each entry comes from the container, or is built with a zero-argumentnewwhen not registered, and must implementHttp/Contract/RestRouteMiddlewareInterface(process(WP_REST_Request, callable $next): WP_REST_Response). A wrong type throws at dispatch: it is a wiring bug, not a request error. - Runs the chain around the action. A middleware may short-circuit by returning its own response, so a rejected request never resolves the controller or its arguments.
- Resolves the controller (container first, else
new), then the action arguments: a parameter typedWP_REST_Requestgets the request; any other parameter gets$request->get_param($name), or its declared default when the param is absent. - A returned
WP_REST_Responseis passed through; any other value is wrapped byRestResponse::success(). - Any throwable from the chain or the action becomes
RestResponse::internalError()(500), so a failure never breaks the REST envelope.
The REST surface shares the framework's declarative attributes (#[Route], #[Auth], #[Middleware]) with the admin surface. It does not share the framework's HttpFoundation/PSR-15 pipeline: REST middleware speaks WP_REST_Request/WP_REST_Response so that a denial stays inside the REST envelope.
Authentication seam
Http/Contract/RequestAuthenticatorInterfacehas two methods:resolveUser(WP_REST_Request): WP_User|WP_Error|null(WP_Errorwhen a presented credential was rejected,nullwhen the request is anonymous) andisAdmin(WP_REST_Request): bool.Http/Auth/WpSessionAuthenticatoris the default: the current WordPress session (cookie auth) only;isAdmin()checks theadministratorrole.- A consumer that accepts bearer tokens composes an authenticator that tries the token and falls back to the session one, then injects it into
WpRestKerneland its REST controllers. The adapter carries no token logic (guard:tests/Architecture/NoBundledTokenAuthTest.php).
Admin
Admin/AdminRouteRegistrar turns a wp-admin menu tree into one dispatch pipeline.
register(MenuPage $main, list<SubMenuPage> $subPages)adds the menu page and its submenus. Every page's callback isrenderApp(). Each page records its route base and capability; a submenu without a capability inherits the main page's.pageHookSuffixes()returns the hook suffixes for the consumer's asset-enqueue guard.renderApp()readspage,routeand the request method, then checks the page's capability throughUserSupport::currentUserCan()(fail-closed, defaulting tomanage_optionsfor an unknown page) before dispatching. The check runs here becauserenderApp()is also reachable throughadmin_initandadmin-ajax.php, where the WordPress menu gate never runs.handleInertiaRequest()(wire onadmin_init) handles an Inertia XHR for one of this registrar's pages early and terminates before WordPress emits the admin HTML shell.dispatch($page, $route, $method)is pure (no superglobals). The path is/{route}whenrouteis set, else the page's route base. If the adapter router has no match for the path and method, the injected fallback callable runs with(page, path): the adapter forces neither Inertia nor a default page. Otherwise it builds a PSR-7 request for the synthetic path and runs it through the injectedHttpKernelInterface, emitting the response through the injectedResponseEmitterInterface.
Routing for the admin surface:
Http/Routing/WpRouter(RouterInterface) holds a SymfonyRouteCollection.scanAnnotations()loads controller#[Route]attributes through the frameworkRouteLoaderInterface;register()adds an imperative route.registerDefaultRoutes()adds aroute_not_foundroute (/404) and the global requirementsany(.*),id([0-9]+) anduuid([0-9a-fA-F\-]{36}).generateUrl()returns Symfony paths (for example/collaborators/42), notadmin.phpURLs; an unknown route name falls back toroute_not_found. The request context keeps an empty base URL because admin dispatch matches the synthetic path carried byroute, not the physical/wp-admin/...URI.
Http/WpHttpKernel extends the framework HttpKernel (route matching, middleware pipeline, #[Auth] and #[Middleware]) and overrides applyPlatformAuth() to apply #[Nonce]:
Security/Attribute/Nonce(action, param = '_wpnonce', require = true)on a method, or else on the class.- The nonce value is read from the named request field, then the
X-WP-Nonceheader. - A required nonce that is missing or fails
wp_verify_nonce()throwsMiddagAuthorizationException(403). This is the counterpart of the Moodle adapter's#[Sesskey].
Public
Http/Routing/PublicRouteRegistrar maps pretty URLs to handlers through the rewrite system.
- The query var is
{component}_route, built fromsanitize_key(componentName())with-turned into_. addRoute($name, $regex, $handler):$regexis a WordPress rewrite regex;$handlerruns ontemplate_redirectand owns its output and exit.register()(call oninit) adds the query var on thequery_varsfilter, adds one rewrite rule per route (index.php?{component}_route={name}, positiontop) and hooksdispatch()ontemplate_redirect. When no route matched,dispatch()does nothing and WordPress renders the normal template.flushRules()persists the rule set. It is expensive: call it from the plugin's activation and deactivation (Runtime/PluginLifecycle), never on a normal request.
// composition root
$lifecycle->onActivate(function () use ($public): void {
$public->register(); // add the rules for this request
$public->flushRules(); // then persist them
});
$lifecycle->onDeactivate(fn () => $public->flushRules());Not targeted
Widgets (register_widget / WP_Widget) and admin-ajax.php action handlers have no surface in the adapter.