skip to content

A small PHP framework maps #[Route] and #[RequiresPermission] attributes on controller methods to routes and access checks; how should it scan, validate and cache them safely?

level: seniorimportance: should knowfreq 30%

answer

  1. attributes are per declaration
  2. an override drops the parent's attributes
  3. IS_INSTANCEOF for a permission hierarchy
  4. instantiate everything at build time
  5. compile the route table to a PHP file

basics

~20 s

Scan each controller method's getAttributes() once at build time, instantiate every attribute so errors fail CI, deny routes that lack a permission attribute, and write the result to a generated PHP file so requests never repeat the reflection scan.

solid answer

~40 s

Treat the attribute scan as a build step. Walk the controller classes, call `getAttributes(Route::class)` and `getAttributes(Permission::class, ReflectionAttribute::IS_INSTANCEOF)` on each method, and call `newInstance()` on all of them so a wrong target, a repeated attribute or a bad argument fails the build. Remember attributes are **not inherited**: a class attribute on a parent is not reported for the child, and a child that overrides a method without repeating `#[RequiresPermission]` has no permission metadata — so the access check must **fail closed**, denying a route with no explicit permission or public marker. A typo such as `#[Rout]` is simply not matched, so a test should instantiate every attribute on controller methods. Finally, because PHP builds each request from scratch, dump the resolved table to a generated PHP file that OPcache keeps, instead of reflecting per request.

code

php · 22 lines
php
<?php
declare(strict_types=1);

interface PermissionAttribute {}

#[Attribute(Attribute::TARGET_METHOD)]
final class RequiresPermission implements PermissionAttribute
{
    public function __construct(public readonly string $name) {}
}

#[Attribute(Attribute::TARGET_METHOD)]
final class PublicRoute implements PermissionAttribute {}

function permissionFor(ReflectionMethod $m): PermissionAttribute
{
    $attrs = $m->getAttributes(PermissionAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
    if (count($attrs) !== 1) {
        throw new LogicException($m->class . '::' . $m->name . ' needs exactly one permission attribute');
    }
    return $attrs[0]->newInstance();
}

go deeper

for a junior

Know that a router can find #[Route] on methods through Reflection and that each attribute belongs to the declaration it is written on.

for a middle

Explain IS_INSTANCEOF for a family of permission attributes and why errors appear only when newInstance() runs.

for a senior

Show the production judgement: fail closed on missing permission metadata, catch overrides and typos in CI, and ship a generated route table instead of scanning per request.

for a principal

Weigh attribute-driven routing and authorisation against a central explicit route file, considering auditability of access rules and how reviewers spot a widened permission.

## The scenario A small in-house framework lets developers write: ```php #[Route('/invoices/{id}', methods: ['GET'])] #[RequiresPermission('invoice.read')] public function show(int $id): Response { /* ... */ } ``` The framework must turn that metadata into a route table and an access check. The PHP attribute API is simple; the design questions are where the failures surface and what happens when metadata is missing. ## Rule 1: attributes belong to one declaration PHP attaches attributes to the exact declaration they are written on. Reflection reports them for that declaration only: - `ReflectionClass::getAttributes()` on a child class does **not** include the parent class's attributes; - a child method that **overrides** a parent method without repeating its attributes reports **none** — the parent's `#[RequiresPermission]` is gone for that route; - a method the child inherits without overriding still reports the parent declaration's attributes, because reflection finds the parent's method; - members imported from a trait carry the trait's attributes, since trait members are copied into the class. For routing, a lost `#[Route]` is visible: the endpoint 404s. For authorisation, a lost `#[RequiresPermission]` is dangerous: the route still exists and, in a naive design, is now open. ## Rule 2: fail closed on missing metadata The access layer should treat "no permission attribute" as **deny**, and require an explicit marker such as `#[PublicRoute]` for anonymous endpoints. Then an override that forgets the attribute breaks loudly instead of silently widening access. The build step can also assert that every routed method carries exactly one of the two. ## Rule 3: model the permission family as a type Define an abstract base or interface — `interface PermissionAttribute` — implemented by `RequiresPermission`, `RequiresRole` and `PublicRoute`. Read them with `getAttributes(PermissionAttribute::class, ReflectionAttribute::IS_INSTANCEOF)`; without the flag, the filter only matches the exact class name and returns nothing. ## Rule 4: make errors surface at build time, not per request Userland attribute errors are lazy: 1. a wrong target or an illegal repetition is only reported by `newInstance()`; 2. a misspelt attribute (`#[Rout(...)]`) compiles, is never matched by `getAttributes(Route::class)`, and the route silently does not exist; 3. constructor validation — a path without a leading `/`, an empty permission name — runs only in `newInstance()`. So the scanner should call `newInstance()` on every attribute it owns, and a test should iterate **all** attributes on every controller method and instantiate each one, which turns `Attribute class "Rout" not found` into a red build. ## Rule 5: do not reflect on every request PHP's usual model is **share-nothing**: each request starts with a fresh set of objects and nothing built in the previous request is still in memory. A router that reflects over all controllers in its bootstrap repeats that scan on every request. The standard answer: - run the scan in a CLI command or on first boot in development; - `var_export()` the resolved table — plain arrays of paths, HTTP methods, handler names and permission strings — into a generated PHP file; - `require` that file at runtime; OPcache keeps the compiled array in shared memory, so loading it is cheap; - regenerate it on deploy, and in development when controller files change. | Concern | Naive design | Robust design | |---|---|---| | Override drops permission | route becomes public | request denied, build assertion fails | | Attribute typo | route silently missing | test instantiating all attributes fails | | Wrong target or repetition | error on the first request that reads it | error during the build scan | | Scan cost | reflection on every request | generated file loaded from OPcache | ## What stays out of attributes Attribute constructors receive only the literal arguments, so keep attribute classes as readonly value objects. The permission *decision* — looking up the user, checking roles — belongs in a service the router calls with the attribute's data.

  • Why is a subclass that overrides a PHP controller method a security risk for attribute-based authorisation?
    Attributes are not inherited by an overriding method, so `getAttributes()` on the override returns nothing from the parent. If the access layer treats missing metadata as allowed, the override quietly makes the route public. Deny by default and assert at build time.
  • Where should the permission check itself live if the attribute only names the permission?
    In a service the router or middleware calls with the attribute's value, for example an authoriser that receives the current user and `'invoice.read'`. The attribute class stays a readonly value object, because PHP calls its constructor with the literal arguments and cannot inject dependencies.

saying these in an interview costs you the question

  • An overriding method keeps the parent method's attributes automatically.
  • A class attribute on a parent controller also applies to its subclasses.
  • A misspelt attribute name makes PHP fail when the controller loads.
  • Reflecting over all controllers on every request is free under PHP-FPM.
  • Routes without a permission attribute should default to allowed.