Skip to content

Attribute Routing & Auth Model ​

Reference for middag-io/framework's #[Route]/#[Auth] attribute model: controller shapes, the parameter resolver chain, the auth pipeline, and CSRF. Backs FW-002. Provenance: legacy vault ADR-801 (routing and controller model, 2026-04-04) and ADR-301 (auth contracts, 2026-03-29), merged into one mechanism.

Attribute usage ​

php
#[Route(path: '/items', name: 'items_index', methods: ['GET'])]
#[Auth(login: true, capabilities: ['local/middag:manage'])]
public function index(): Response { ... }

#[Route(path: '/ping', name: 'ping', methods: ['GET'])]
#[Auth(login: false)]
public function ping(): JsonResponse { ... }

#[Route] is Symfony's Attribute\Route (Symfony\Component\Routing\Attribute\Route), never Annotation\Route. #[Auth] (Middag\Framework\Http\Attribute\Auth) is read by reflection on the method first, then the class if the method carries none — method wins over class, no attribute means open (public route).

Controller shapes ​

ModeBase classNotes
Server-rendered pageHttp/Controller/AbstractController.php, composed with a host-adapter template rendererThe host template engine (Mustache, Blade, ...) is adapter-side composition, not a separate framework base class — the framework itself is host-neutral
JSON APIHttp/Controller/AbstractApiController.phpForces every response to JSON (isJson() hardwired true); wraps payloads in a {success, data, ...} envelope, errors in {success: false, message, error_code, debug?}
Inertia SPAHttp/Controller/AbstractController.php + InertiaAdapter::render()See FW-006

Parameter resolver chain ​

Fixed order, first resolver that can resolve a given parameter wins. Built per-request in HttpKernel::buildParameterResolver(), so request-bound resolvers share the same Request instance the kernel used (route params already populated in $request->attributes):

OrderResolverFile
1Request objectHttp/Resolver/RequestResolver.php
2Form requestHttp/Resolver/FormRequestResolver.php
3Form bridge (AbstractForm)Http/Resolver/FormResolver.php
4Validated DTO (#[ValidatedDto])Http/Resolver/ValidatedDtoResolver.php
5Container-resolved serviceHttp/Resolver/ContainerResolver.php
6Inertia-aware parameterHttp/Resolver/InertiaResolver.php
7Route parameterHttp/Resolver/RouteParameterResolver.php

MethodParameterResolver (Http/Resolver/MethodParameterResolver.php) is the dispatcher that walks this ordered list per controller-method parameter — it is not itself a chain member. FormResolver bridges AbstractForm (see the FW-003 ADR); ValidatedDtoResolver backs the #[ValidatedDto] path (see architecture.md §9.3).

Plugin-aware URL generation ​

Symfony's RequestContext doesn't natively support multiple base URLs per route. RouteLoader (Http/Routing/RouteLoader.php) scans #[Route] attributes into a plain RouteCollection; it is explicitly designed for extension — "platform-specific route loaders extend this with adapter-specific base URL resolution." Resolving a multi-base-URL scheme for a specific host (e.g. producing a Moodle-native URL) is host-adapter territory, not a framework contract.

Request pipeline — order and gate behavior ​

HttpKernel::handle() runs these stages for a controller action, in this order, before any action code executes:

  1. Route match against the pre-built RouteCollection.
  2. Auth flags from #[Auth] (HttpKernel::applyRouteAuth()): setRequireLogin(), then setRequireCapabilities() when capabilities is non-empty, then setRequireCapabilityRequirements() when requirements is non-empty and the controller implements CapabilityRequirementAwareInterface.
  3. applyPlatformAuth() — a no-op in the framework; adapters (Moodle, WordPress) override it to read their own platform-specific attributes (e.g. #[Sesskey], #[Nonce]) and apply additional flags. A route marked #[Auth(login: false)] returns before this step: the controller is told to skip its own auth pass (PublicRouteAwareInterface::disableAuthentication(), when implemented) and applyPlatformAuth() is not called.
  4. The OSS login gate (guardAuthenticatedAccess(), below).
  5. preHandle().
  6. Parameter resolution through the resolver chain above.
  7. Route middleware: registrar-declared middleware from the route defaults (outermost), then class-level #[Middleware], then method-level #[Middleware].
  8. The action.

Closure controllers skip stages 2 to 6: they have no class or method to reflect, so only the registrar-declared middleware chain wraps them.

Unresolvable middleware fails closed. Each middleware id must be bound in the container or name an existing class implementing RouteMiddlewareInterface. Anything else throws a RuntimeException before the action runs, which the kernel maps to a 500; a malformed middleware route default throws InvalidArgumentException. A misdeclared chain never results in the action running unprotected.

#[Auth] overrides, #[Middleware] stacks. Both attributes are read from the method and the class, with opposite merge rules. #[Auth] is an override: a method-level attribute replaces the class-level one, so the most specific requirement is authoritative. #[Middleware] stacks: class-level and method-level entries accumulate (class outermost, method innermost, each in declaration order), and an id named by more than one source runs once, at its outermost position.

The login gate is OSS-enforced, conditionally. When #[Auth(login: true)] applies and an AuthenticatorInterface implementation is bound in the container, an unauthenticated request is denied:

Request kindFailure response
Inertia (X-Inertia header present)409 + X-Inertia-Location header — the client does a full browser visit to the login path
JSON/XHR APIthrows MiddagAuthenticationException → 401
Plain browser visit303 redirect to AuthenticatorInterface::loginPath()

When no AuthenticatorInterface is bound, the gate is inert (returns null/allows the request) — this is how host-delegated auth (Moodle, WordPress, which wire setRequireLogin() to their own platform checks instead) stays unaffected by the OSS gate.

The capability gate is adapter-enforced, not OSS-enforced. AbstractController::setRequireCapabilities() is a no-op by default. The framework kernel never throws MiddagAuthorizationException itself — a host adapter's controller subclass overrides setRequireCapabilities() (string surface) and/or implements CapabilityRequirementAwareInterface::setRequireCapabilityRequirements() (rich surface) to perform the actual capability check and decide the failure response (typically 403 via MiddagAuthorizationException for an API controller, or a host-specific redirect/error page for a page controller).

CSRF ​

Http/Middleware/VerifyCsrfMiddleware.php is a host-neutral PSR-15 middleware, independent of the exception hierarchy above: for unsafe methods (POST/PUT/PATCH/DELETE) it validates a token from the X-CSRF-Token/X-XSRF-TOKEN header or a _token body field, and returns a plain 419 response directly ("CSRF Token Mismatch") on failure — it does not throw MiddagAuthorizationException. Safe methods (GET, etc.) pass straight through. It also shares the current token as the Inertia csrf_token prop and sets a JS-readable XSRF-TOKEN cookie (SameSite=Lax) so the client can echo the token back automatically.

#[Auth] capability requirements ​

Http/Attribute/Auth.php keeps capabilities: list<string> as first-class, plus a requirements: list<CapabilityRequirement> field:

  • CapabilityReference (Http/Auth/CapabilityReference.php) — a host-neutral {key, host} pair identifying a capability.
  • CapabilityRequirement (Http/Auth/CapabilityRequirement.php) — wraps a bare string, a CapabilityReference, a CapabilityDefinitionInterface instance, or a CapabilityDefinitionInterface class-string, plus free-form options (e.g. a Moodle contextlevel).
  • CapabilityDefinitionInterface (Http/Contract/CapabilityDefinitionInterface.php) — implemented by a host/domain object that needs to describe a capability beyond a bare string key.

Auth::__construct() normalizes every capabilities/requirements entry passed in through CapabilityRequirement::listFrom(), then re-derives the flat capabilities: list<string> string surface from the resulting requirement list — so the string surface and the rich surface never drift apart. A controller opts into the rich surface by implementing CapabilityRequirementAwareInterface; the kernel calls it in addition to (not instead of) the string-surface setRequireCapabilities(), so adapters that only implement the string surface keep working unchanged.

Auth session primitive ​

AuthenticatorInterface (Http/Contract/AuthenticatorInterface.php) is the standalone OSS authentication primitive — it owns session state (who is logged in), not credential verification: the application verifies credentials against its own user store, then calls login(int $userId, array $attributes = []); check()/id()/user()/logout()/loginPath() round out the contract. Binding an implementation to this interface in the container is what makes #[Auth(login: true)] enforce (see the login gate above); leaving it unbound leaves host-delegated auth (Moodle, WordPress) unaffected.

Anti-patterns ​

  • Calling a host's native capability check directly instead of going through #[Auth] + the controller's capability hook.
  • Performing auth checks in a controller constructor instead of #[Auth] or inside the action body.
  • Requiring a CSRF/session-token check on a GET route — that check exists to protect mutations, and VerifyCsrfMiddleware only inspects unsafe methods.
  • Checking a capability inside a template/view instead of passing a pre-computed boolean prop.
  • Swallowing MiddagAuthorizationException silently instead of letting it propagate to the kernel's exception mapping.

Route collection and boot-time cost ​

RouteLoader scans #[Route] attributes off controllers into a RouteCollection, but the OSS HttpKernel only receives a pre-built collection: the host assembles routes (a standalone app lists them; the adapters ship their own loaders). Scanning is a boot-time cost that a host can cache, not a per-request cost; the collection grows linearly with the number of registered controllers.

The host-adapter bridge contracts that resolve user, session and capability data (UserContextResolverInterface and siblings) are listed in Bridge Contracts.

MIDDAG © 2015-2026