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
#[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
| Mode | Base class | Notes |
|---|---|---|
| Server-rendered page | Http/Controller/AbstractController.php, composed with a host-adapter template renderer | The host template engine (Mustache, Blade, ...) is adapter-side composition, not a separate framework base class — the framework itself is host-neutral |
| JSON API | Http/Controller/AbstractApiController.php | Forces every response to JSON (isJson() hardwired true); wraps payloads in a {success, data, ...} envelope, errors in {success: false, message, error_code, debug?} |
| Inertia SPA | Http/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):
| Order | Resolver | File |
|---|---|---|
| 1 | Request object | Http/Resolver/RequestResolver.php |
| 2 | Form request | Http/Resolver/FormRequestResolver.php |
| 3 | Form bridge (AbstractForm) | Http/Resolver/FormResolver.php |
| 4 | Validated DTO (#[ValidatedDto]) | Http/Resolver/ValidatedDtoResolver.php |
| 5 | Container-resolved service | Http/Resolver/ContainerResolver.php |
| 6 | Inertia-aware parameter | Http/Resolver/InertiaResolver.php |
| 7 | Route parameter | Http/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:
- Route match against the pre-built
RouteCollection. - Auth flags from
#[Auth](HttpKernel::applyRouteAuth()):setRequireLogin(), thensetRequireCapabilities()whencapabilitiesis non-empty, thensetRequireCapabilityRequirements()whenrequirementsis non-empty and the controller implementsCapabilityRequirementAwareInterface. 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) andapplyPlatformAuth()is not called.- The OSS login gate (
guardAuthenticatedAccess(), below). preHandle().- Parameter resolution through the resolver chain above.
- Route middleware: registrar-declared middleware from the route defaults (outermost), then class-level
#[Middleware], then method-level#[Middleware]. - 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 kind | Failure response |
|---|---|
Inertia (X-Inertia header present) | 409 + X-Inertia-Location header — the client does a full browser visit to the login path |
| JSON/XHR API | throws MiddagAuthenticationException → 401 |
| Plain browser visit | 303 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, aCapabilityReference, aCapabilityDefinitionInterfaceinstance, or aCapabilityDefinitionInterfaceclass-string, plus free-formoptions(e.g. a Moodlecontextlevel).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
GETroute — that check exists to protect mutations, andVerifyCsrfMiddlewareonly inspects unsafe methods. - Checking a capability inside a template/view instead of passing a pre-computed boolean prop.
- Swallowing
MiddagAuthorizationExceptionsilently 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.