Skip to content

6.x Adding Attribute routing#19293

Open
josbeir wants to merge 14 commits into6.xfrom
6.x-attribute-routing
Open

6.x Adding Attribute routing#19293
josbeir wants to merge 14 commits into6.xfrom
6.x-attribute-routing

Conversation

@josbeir
Copy link
Copy Markdown
Contributor

@josbeir josbeir commented Feb 21, 2026

Refs #19228

Summary

Implements the attribute-routing RFC from #19228 by introducing route attributes, wiring attribute discovery/connection into routing, and aligning controller action argument binding for named/positional route params.

This also extends AttributeResolver metadata so routing can make controller-instantiability and action-eligibility decisions without reflection in the connector layer.

What changed

  • Added routing attributes for controller/action mapping:
    • Route, HTTP method shortcuts (Get, Post, Put, Patch, Delete, Options, Head)
    • class-level attributes: Scope, Prefix, RouteClass, Middleware, Extensions, Resource
  • Added AttributeRouteConnector to:
    • aggregate class/method attribute metadata
    • connect method routes and resource routes
    • derive _argsByName metadata from pass/placeholders
    • skip non-instantiable controller targets via resolver metadata
    • ignore non-public method targets using resolver-provided method visibility metadata
  • Added RouteBuilder::attributes() integration to connect resolver-discovered routes.
  • Updated ControllerFactory to support _argsByName mapping while preserving existing positional/named pass behavior.
  • Kept compatibility behavior for non-attribute routing (no strict route-placeholder vs action-signature exception by default).

AttributeResolver updates (and why)

  • Extended AttributeTarget metadata with:
    • declaringClassType (class / interface / trait / enum)
    • isDeclaringClassAbstract
    • methodVisibility (public / protected / private, for method-related targets)
    • computed helpers: isInstantiableDeclaringType() and isPublicMethodTarget()
  • Added MethodVisibility enum.
  • Updated Parser to populate class-type/abstractness metadata once per class and method visibility once per method, then propagate into method/parameter targets.

Why this was needed:

  • Routing must ignore abstract or non-class controller declarations.
  • Routing should only connect public action methods.
  • Centralizing this in resolver metadata removes connector reflection checks, keeps connector logic simpler, and makes metadata reusable for other consumers.

Forward-looking note

  • The new methodVisibility metadata also opens a path for future “auto-scoped routes” mode, where all public controller methods inside a scope could be connected automatically without relying on fallback routing.

Usage examples (after setting up AttributeResolver)

// config/routes.php

$routes->connectAttributes();

$routes->scope('/', function (\Cake\Routing\RouteBuilder $routes): void {
   // Manual routes...
});
// src/Controller/ArticlesController.php
use Cake\Routing\Attribute\Get;
use Cake\Routing\Attribute\Scope;

#[Scope('/articles', namePrefix: 'articles:')]
class ArticlesController extends AppController
{
    #[Get('/', name: 'index')]
    public function index(): void
    {
    }

    #[Get('/{id}', name: 'view')]
    public function view(string $id): void
    {
    }
}
use Cake\Routing\Attribute\Resource;

#[Resource('articles', only: ['index', 'view'])]
class ArticlesController extends AppController
{
}

@josbeir josbeir added this to the 6.0 milestone Feb 21, 2026
@josbeir josbeir self-assigned this Feb 21, 2026
Copilot AI review requested due to automatic review settings February 21, 2026 12:38
@josbeir josbeir changed the title 6.x attribute routing 6.x Adding Attribute routing Feb 21, 2026

This comment was marked as outdated.

@josbeir josbeir force-pushed the 6.x-attribute-routing branch from 6f471ae to a8834d2 Compare February 21, 2026 16:12
@josbeir josbeir force-pushed the 6.x-attribute-routing branch from a8834d2 to 77ca3ef Compare February 21, 2026 16:23
@LordSimal
Copy link
Copy Markdown
Contributor

  • Should HTTP method-style attributes remain Get/Post/Put/... (current), or should naming move toward an uppercase style?

I have forgotten, that it would break our code style rules - therefore I am fine having the PascalCase

Copy link
Copy Markdown
Contributor

@LordSimal LordSimal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love so see this finalized and working 🥳

…rs for extra guarding and attribute based automatic route connecting

This comment was marked as outdated.

This comment was marked as outdated.

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Feb 23, 2026

@josbeir In your usage example the $routes->attributes(); in inside a scope. It should be moved out to avoid someone incorrectly thinking that the call needs to be done per scope.

@josbeir
Copy link
Copy Markdown
Contributor Author

josbeir commented Feb 23, 2026

@josbeir In your usage example the $routes->attributes(); in inside a scope. It should be moved out to avoid someone incorrectly thinking that the call needs to be done per scope.

Updated, but it actually works like you'd expect. If you put it in a scope then all discovered attributes from a resolver config (which is the only optional argument of attributes() would follow that scope.

So yes for root attributes it can be added inside the '/' scope or outside, they will all start at root level. The example illustrated that it also work likes that (handy feature actually 😎)

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Feb 23, 2026

Updated, but it actually works like you'd expect. If you put it in a scope then all discovered attributes from a resolver config (which is the only optional argument of attributes() would follow that scope.

Oh, so if I do $routes->scope('/admin', function () { $routes->attributes() }); only, then any attributes I might might set like #[Scope('/articles', namePrefix: 'articles:')] will be ignored?

@josbeir
Copy link
Copy Markdown
Contributor Author

josbeir commented Feb 23, 2026

Oh, so if I do $routes->scope('/admin', function () { $routes->attributes() }); only, then any attributes I might might set like #[Scope('/articles', namePrefix: 'articles:')] will be ignored?

No, they just will all be connected to the /admin scope.
So if you set an a Scope attribute for articles then it will be connected to /admin scope. = /admin/articles/....

// routes.php
$routes->scope('/admin', function(RouteBuilder $routes) {
   $routes->attributes();
});

// A controller
#[Scope('/articles')]
class ArticlesController extends AppController {
   
   #[Get('/list')]
   public function index() {
   }
}

will result in /admin/articles/list

Basically the resolver holds the list of discovered route attributes. Then these routes are connected to the scope where attributes() was called in.

So if a resolver configuration is there that targets a specific path/pattern then that config could be used to connect a specific subset of routes to a scope. Not something we should explicitly promote but it is a use case that could be handy.

@LordSimal
Copy link
Copy Markdown
Contributor

@markstory @dereuromark thoughts?

@josbeir josbeir force-pushed the 6.x-attribute-routing branch from 7df7506 to a839d54 Compare March 14, 2026 08:14
@josbeir josbeir force-pushed the 6.x-attribute-routing branch from cbe1660 to ae4c935 Compare March 17, 2026 14:23
@dereuromark
Copy link
Copy Markdown
Member

Minor Observations:

Copilot's point about duplicate regex - The extractPathPlaceholders() duplicating PLACEHOLDER_REGEX from the Route class is a valid concern for maintainability.

Questions I'd have:

  • How does route ordering work? If multiple controllers define overlapping patterns, is it deterministic?
  • What happens with conflicting class-level and method-level middleware declarations?

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Mar 26, 2026

How does route ordering work? If multiple controllers define overlapping patterns, is it deterministic?

I believe it would be non-deterministic and whichever gets loaded first will take precedence.

What happens with conflicting class-level and method-level middleware declarations?

The docblock of AttributeRouteConnector::mergeMiddleware() says:

Merge multiple middleware lists while preserving order.

String middleware names are deduplicated by value. Closures are always appended
since they cannot be meaningfully compared for equality.

@josbeir I don't see tests using the Middleware attribute.

@josbeir
Copy link
Copy Markdown
Contributor Author

josbeir commented Mar 28, 2026

@josbeir I don't see tests using the Middleware attribute.

Added an integration test to confirm registration is working properly trough the resolver, it was already synthetically tested.
Side-note: I added support for registering closure based middlewares but as this is PHP 8.5+ i didn't add any integration tests as php < 8.5 can't parse these type of attributes and returns in errors. (I could conditionally generate a test file but that seems just too much maintenance work and will probably get forgotten later)

Below a little example of how this works (php 8.5+ only):

<?php
#[Scope(path: '/api')]
#[Middleware(static function (ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
    $response = $handler->handle($request);

    return $response->withHeader('X-Api-Version', '2.0');
})]
class ArticlesController extends AppController
{
    #[Get('/articles', 'articles:index')]
    #[Middleware(static function (ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
        // inline rate-limit logic
        return $handler->handle($request);
    })]
    public function index(): void
    {
    }
}

@josbeir
Copy link
Copy Markdown
Contributor Author

josbeir commented Mar 28, 2026

docs PR: cakephp/docs#8257

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Mar 28, 2026

Added an integration test to confirm registration is working properly trough the resolver...

Using route scoped middewares is a 2 step process, first you need to register the middleware (RouteBuilder::registerMiddleware()) and then apply it (RouteBuilder::applyMiddleware()).

So when using the Middleware attribute with string args, you only specify the middlewares to be applied, they still have to be registered isn't?

Edit: I see now the docs clarify the above.

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Mar 28, 2026

I added support for registering closure based middlewares but as this is PHP 8.5+ i didn't add any integration tests as php < 8.5 can't parse these type of attributes and returns in errors

Would be nice if it would be possible to load a file only for PHP 8.5+, so that we don't have an untested feature.

@josbeir
Copy link
Copy Markdown
Contributor Author

josbeir commented Mar 28, 2026

I added support for registering closure based middlewares but as this is PHP 8.5+ i didn't add any integration tests as php < 8.5 can't parse these type of attributes and returns in errors

Would be nice if it would be possible to load a file only for PHP 8.5+, so that we don't have an untested feature.

I'll see what I can whip up, but it is already tested, just not coming from the registry directly

*
* @param array<string> $extensions File extensions.
*/
public function __construct(public array $extensions)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function __construct(public array $extensions)
public function __construct(public array|string $extensions)

This would make it match the argument type for RouteBuilder::setExtensions()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants