Skip to content

RFC - 6.0 - Attribute based routing #19228

@josbeir

Description

@josbeir

Refs #19206

Summary

This RFC proposes introducing attribute-based routing to CakePHP 6.x, allowing developers to define routes directly on controller classes and methods using PHP 8 attributes. This approach complements the existing RouteBuilder configuration while providing a more intuitive, co-located routing experience.

Motivation

  • Co-location: Route definitions live alongside controller code, improving discoverability and maintainability
  • Type Safety: IDE autocompletion and static analysis for route configuration
  • Reduced Boilerplate: Common routing patterns require less configuration
  • Modern PHP: Leverages PHP 8+ attributes, aligning with CakePHP 6's modern PHP requirements
  • Framework Parity: Matches capabilities offered by Symfony, Laravel, and other modern frameworks

Design Goals

  1. Full Feature Parity: Support all existing RouteBuilder capabilities
  2. Opt-in: Completely optional—existing routes.php continues to work
  3. Composable: Class-level and method-level attributes combine intuitively
  4. Cacheable: Integrates with the new Attribute Resolver's caching system
  5. Debuggable: Clear introspection via CLI commands (existing ./bin/cake routes)

Core Attributes

#[Route] - Primary Route Attribute

The main attribute for defining routes on controller methods:

namespace App\Controller;

use Cake\Controller\Controller;
use Cake\Routing\Attribute\Route;

class ArticlesController extends Controller
{
    #[Route('/articles', name: 'articles:index')]
    public function index(): void
    {
        // GET /articles
    }

    #[Route('/articles/{id}', name: 'articles:view', patterns: ['id' => '\d+'])]
    public function view(int $id): void
    {
        // GET /articles/123
    }

    #[Route('/articles', methods: ['POST'], name: 'articles:add')]
    public function add(): void
    {
        // POST /articles
    }
}

Parameters:

Parameter Type Description
path string URL template with {param} placeholders. Use /* for greedy star (multiple segments) or /** for trailing star (remainder as single argument)
name ?string Route name for URL generation
methods array<string> HTTP methods (default: all)
patterns array<string, string> Regex constraints for route parameters (mirrors setPatterns())
defaults array<string, mixed> Default parameter values (supports _scheme, _host, _port, _https, _ext)
pass array<string> Parameters passed as action arguments
persist array<string> Parameters that persist across URL generation (mirrors setPersist())
host ?string Host pattern for subdomain routing
routeClass ?string Custom route class for this specific route

#[RouteClass] - Custom Route Class

Defines the route class for all routes in a controller. Mirrors RouteBuilder::setRouteClass():

use Cake\Routing\Attribute\{Route, RouteClass};
use Cake\Routing\Route\DashedRoute;

#[RouteClass(DashedRoute::class)]
class ArticlesController extends Controller
{
    #[Route('/articles')]  // Uses DashedRoute
    public function index(): void {}

    #[Route('/articles/{id}')]  // Also uses DashedRoute
    public function view(int $id): void {}
}

Behavior:

  • Class-level attribute that applies to all routes in the controller
  • Individual routes can override using the routeClass parameter on #[Route]
  • If not specified, inherits the route class from the parent scope's setRouteClass() call
  • Common classes: DashedRoute, InflectedRoute, or custom route classes

HTTP Method Shortcuts

Convenience attributes for common HTTP verbs:

use Cake\Routing\Attribute\{Get, Post, Put, Patch, Delete, Options, Head};

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

    #[Get('/articles/{id}', 'articles:view')]
    public function view(int $id): void {}

    #[Post('/articles', 'articles:add')]
    public function add(): void {}

    #[Put('/articles/{id}', 'articles:edit')]
    #[Patch('/articles/{id}', 'articles:edit')]
    public function edit(int $id): void {}

    #[Delete('/articles/{id}', 'articles:delete')]
    public function delete(int $id): void {}

    #[Options('/articles')]
    public function options(): void {}

    #[Head('/articles/{id}')]
    public function head(int $id): void {}
}

All HTTP method shortcuts support the same parameters as #[Route]: Get, Post, Put, Patch, Delete, Options, Head.


#[Prefix] - Controller Namespace Prefix

Maps controllers to URL and namespace prefixes (CakePHP's existing prefix system):

namespace App\Controller\Admin;

use Cake\Routing\Attribute\{Prefix, Route};

#[Prefix('Admin', path: '/admin')]
class DashboardController extends Controller
{
    #[Route('/', 'admin:dashboard')]
    public function index(): void
    {
        // GET /admin → App\Controller\Admin\DashboardController::index()
    }

    #[Route('/stats', 'admin:stats')]
    public function stats(): void
    {
        // GET /admin/stats
    }
}

Nested Prefixes:

namespace App\Controller\Admin\Api;

#[Prefix('Admin/Api', path: '/admin/api')]
class UsersController extends Controller
{
    #[Route('/users', 'admin:api:users')]
    public function index(): void
    {
        // GET /admin/api/users → App\Controller\Admin\Api\UsersController
    }
}

#[Scope] - Route Grouping

Groups routes with shared configuration at the class level:

use Cake\Routing\Attribute\{Scope, Route};

#[Scope(
    path: '/api/v1',
    namePrefix: 'api:v1:',
    defaults: ['_ext' => 'json']
)]
class ApiController extends Controller
{
    #[Route('/articles', 'articles')]
    public function articles(): void
    {
        // GET /api/v1/articles.json
        // Route name: api:v1:articles
    }
}

Parameters:

Parameter Type Description
path string URL prefix for all routes in class
namePrefix string Prefix for route names (e.g., 'api:')
defaults array Default values applied to all routes (supports _ext, _https, _host, _port)
patterns array Shared parameter constraints
host ?string Host pattern for all routes (supports *.example.com wildcards)

Plugin Routes

Plugin routes are automatically detected from the controller's namespace. No special attribute is needed:

namespace Blog\Controller;

use Cake\Routing\Attribute\Route;

class ArticlesController extends Controller
{
    #[Route('/posts', 'blog:posts')]
    public function index(): void
    {
        // GET /posts → Blog\Controller\ArticlesController::index()
        // Plugin name is provided by the Attribute Resolver
    }
}

To customize the URL prefix for plugin routes, use #[Scope]:

namespace Blog\Controller;

use Cake\Routing\Attribute\{Scope, Route};

#[Scope(path: '/blog')]
class ArticlesController extends Controller
{
    #[Route('/posts', 'blog:posts')]
    public function index(): void
    {
        // GET /blog/posts → Blog\Controller\ArticlesController::index()
    }
}

#[Middleware] - Route Middleware

Attach middleware to routes using registered middleware names or middleware groups. Middleware and groups must be pre-registered via registerMiddleware() and middlewareGroup() in your application:

// In Application.php or routes.php
$routes->registerMiddleware('rate-limit', new RateLimitMiddleware(100));
$routes->registerMiddleware('csrf', new CsrfProtectionMiddleware());

// Define a middleware group
$routes->middlewareGroup('api', ['authentication', 'rate-limit']);
$routes->middlewareGroup('web', ['csrf', 'authentication']);

// In controller - reference by registered name or group name
use Cake\Routing\Attribute\{Route, Middleware};

#[Middleware('api')]  // Applies the 'api' middleware group
class ApiController extends Controller
{
    #[Route('/articles')]
    public function articles(): void
    {
        // Middleware: authentication, rate-limit (from 'api' group)
    }
}

#[Middleware('authentication')]
class AdminController extends Controller
{
    #[Route('/admin/dashboard')]
    #[Middleware('authorization', 'rate-limit')]
    public function dashboard(): void
    {
        // Middleware: authentication, authorization, rate-limit
    }
}

#[Resource] - RESTful Resources

Auto-generate standard REST routes:

use Cake\Routing\Attribute\Resource;

#[Resource(
    path: '/articles',
    only: ['index', 'view', 'add', 'edit', 'delete']
)]
class ArticlesController extends Controller
{
    public function index(): void {}   // GET    /articles
    public function view($id): void {} // GET    /articles/{id}
    public function add(): void {}     // POST   /articles
    public function edit($id): void {} // PUT    /articles/{id}
    public function delete($id): void {} // DELETE /articles/{id}
}

Parameters:

Parameter Type Description
path ?string URL path prefix (defaults to inflected controller name)
only array<string> Limit to specific actions: index, view, add, edit, delete
actions array<string, string> Map resource actions to custom controller methods
map array Add additional resource routes
prefix ?string Controller prefix for nested resources
id string Regex pattern for ID matching (default: integers/UUIDs)
inflect string URL inflection type: dasherize (default), underscore
connectOptions array Options passed to connect() including routeClass

#[Extensions] - File Extensions

Handle URL extensions (.json, .xml, etc.):

use Cake\Routing\Attribute\{Route, Extensions};

#[Extensions(['json', 'xml'])]
class ApiController extends Controller
{
    #[Route('/data')]
    public function data(): void
    {
        // Matches: /data, /data.json, /data.xml
        // $this->request->getParam('_ext')
    }

    #[Route('/feed')]
    #[Extensions(['rss', 'atom'])]
    public function feed(): void
    {
        // Matches: /feed.rss, /feed.atom (replaces class-level extensions)
    }
}

Note: Extensions at the method level replace (not merge with) class-level extensions, mirroring setExtensions() behavior in RouteBuilder where extensions apply to routes connected after they are set.


Advanced Features

Parameter Patterns

Define regex constraints using the patterns parameter (mirrors setPatterns()):

use Cake\Routing\Attribute\Route;

class ArticlesController extends Controller
{
    // Match numeric IDs
    #[Route('/articles/{id}', patterns: ['id' => '\d+'])]
    public function view(int $id): void {}

    // Match UUIDs
    #[Route('/articles/{uuid}', patterns: ['uuid' => '[a-f0-9-]{36}'])]
    public function viewByUuid(string $uuid): void {}

    // Match date components
    #[Route('/archive/{year}/{month}', patterns: [
        'year' => '[12][0-9]{3}',
        'month' => '0[1-9]|1[012]'
    ])]
    public function archive(int $year, int $month): void {}

Subdomain Routing

use Cake\Routing\Attribute\{Route, Scope};

#[Scope(host: '{tenant}.example.com')]
class TenantController extends Controller
{
    #[Route('/dashboard')]
    public function dashboard(): void
    {
        $tenant = $this->request->getParam('tenant');
    }
}

// Mobile subdomain
#[Scope(host: 'm.{domain}')]
class MobileController extends Controller
{
    #[Route('/')]
    public function index(): void {}
}

Greedy and Trailing Stars

Capture additional path segments using star patterns:

use Cake\Routing\Attribute\Route;

class PagesController extends Controller
{
    // Greedy star: captures multiple segments as separate arguments
    #[Route('/pages/*', 'pages:display')]
    public function display(mixed ...$args): void
    {
        // /pages/about/team → display('about', 'team')
    }

    // Trailing star: captures remainder as single argument (preserves slashes)
    #[Route('/files/**', 'files:serve')]
    public function serve(string $path): void
    {
        // /files/docs/2024/report.pdf → serve('docs/2024/report.pdf')
    }
}

Nesting & Composition

Unlike the closure-based RouteBuilder API, PHP attributes cannot physically nest. Instead, nesting is achieved through different mechanisms:

Prefix Nesting via Namespace

For prefixes, the controller's namespace already implies the hierarchy. The #[Prefix] attribute primarily exists to customize the URL path when it differs from the namespace:

namespace App\Controller\Admin\Api;

// Option 1: Namespace-inferred (recommended)
// No #[Prefix] needed - detected from namespace
class UsersController extends Controller
{
    #[Route('/users')]
    public function index(): void
    {
        // GET /admin/api/users → App\Controller\Admin\Api\UsersController
        // Prefix automatically set to 'Admin/Api' from namespace
    }
}

// Option 2: Explicit path customization
#[Prefix('Admin/Api', path: '/backend/api')]
class UsersController extends Controller
{
    #[Route('/users')]
    public function index(): void
    {
        // GET /backend/api/users → App\Controller\Admin\Api\UsersController
        // Prefix is 'Admin/Api', but URL path is customized
    }
}

Scope Stacking

Multiple #[Scope] attributes on the same class stack and merge:

use Cake\Routing\Attribute\{Scope, Route};

#[Scope(path: '/api')]
#[Scope(path: '/v1', namePrefix: 'api:v1:')]
#[Scope(defaults: ['_ext' => 'json'])]
class ArticlesController extends Controller
{
    #[Route('/articles', 'articles')]
    public function index(): void
    {
        // GET /api/v1/articles.json
        // Route name: api:v1:articles
    }
}

Merge behavior:

  • path: Concatenated in order (/api + /v1 = /api/v1)
  • namePrefix: Concatenated in order
  • defaults: Merged (later values override earlier)
  • patterns: Merged (later values override earlier)
  • host: Last one wins (cannot combine hosts)

Middleware Stacking

Middleware attributes always stack (class-level + method-level):

#[Middleware('authentication')]
#[Middleware('rate-limit')]
class ApiController extends Controller
{
    #[Route('/public')]
    public function publicEndpoint(): void
    {
        // Middleware: authentication, rate-limit
    }

    #[Route('/admin')]
    #[Middleware('authorization')]
    public function adminEndpoint(): void
    {
        // Middleware: authentication, rate-limit, authorization
    }
}

Base Controller Inheritance

Attributes on parent controllers are inherited by child controllers:

// Base API controller
#[Scope(path: '/api', defaults: ['_ext' => 'json'])]
#[Middleware('api-auth')]
abstract class ApiController extends Controller {}

// V1 extends base - inherits scope and middleware
#[Scope(path: '/v1', namePrefix: 'v1:')]
class ArticlesV1Controller extends ApiController
{
    #[Route('/articles', 'articles')]
    public function index(): void
    {
        // GET /api/v1/articles.json
        // Middleware: api-auth
        // Route name: v1:articles
    }
}

// V2 with different configuration
#[Scope(path: '/v2', namePrefix: 'v2:')]
#[Middleware('rate-limit')]
class ArticlesV2Controller extends ApiController
{
    #[Route('/articles', 'articles')]
    public function index(): void
    {
        // GET /api/v2/articles.json
        // Middleware: api-auth, rate-limit
        // Route name: v2:articles
    }
}

Inheritance behavior:

  • Parent #[Scope] paths/namePrefixes are prepended to child's
  • Parent #[Middleware] is applied before child's
  • Parent #[Extensions] are inherited (child can override)
  • #[Prefix] is NOT inherited (determined by each controller's namespace)
  • #[RouteClass] is inherited (child can override)

Plugin + Prefix Combination

Plugins and prefixes combine naturally:

namespace Blog\Controller\Admin;

// Plugin detected from namespace: Blog
// Prefix detected from namespace: Admin
class ArticlesController extends Controller
{
    #[Route('/articles')]
    public function index(): void
    {
        // GET /blog/admin/articles → Blog\Controller\Admin\ArticlesController
    }
}

// With path customization
#[Scope(path: '/my-blog/backend')]
class PostsController extends Controller
{
    #[Route('/posts')]
    public function index(): void
    {
        // GET /my-blog/backend/posts → Blog\Controller\Admin\PostsController
    }
}

Integration with Attribute Resolver

Routes are discovered through the existing Attribute Resolver infrastructure via a new attributes() method on RouteBuilder. This method is called once at the top level—all scoping, prefixing, and middleware configuration is defined in the controller attributes themselves:

// config/routes.php
use Cake\Routing\RouteBuilder;

return function (RouteBuilder $routes): void {
    // Load all attribute-based routes from controllers
    $routes->attributes();

    // Traditional routes still work alongside
    $routes->connect('/custom', ['controller' => 'Pages', 'action' => 'custom']);
};

The attributes() method queries the Attribute Resolver, which has already scanned and cached all controller attributes. Route configuration comes from the attributes on controllers:

// Prefixes, scopes, middleware - all defined on the controller
#[Prefix('Admin', path: '/admin')]
#[Middleware('authentication')]
class DashboardController extends Controller
{
    #[Route('/')]
    public function index(): void {}
}

#[Scope(path: '/api/v1', defaults: ['_ext' => 'json'])]
#[Middleware('api-auth')]
class ArticlesApiController extends Controller
{
    #[Route('/articles')]
    public function index(): void {}
}

This keeps routes.php minimal while route definitions stay co-located with controller code.


Precedence & Ordering

Route matching follows CakePHP's existing first-match-wins behavior:

  1. Position in routes.php: Routes connected before $routes->attributes() match first
  2. Attribute route order: Deterministic ordering by class name (alphabetical), then method definition order
  3. Traditional routes after: Routes connected after $routes->attributes() match last
// config/routes.php
return function (RouteBuilder $routes): void {
    // These match FIRST
    $routes->connect('/articles/featured', ['controller' => 'Articles', 'action' => 'featured']);

    // Attribute routes match in deterministic order
    $routes->attributes();

    // Fallbacks match LAST
    $routes->fallbacks();
};

Migration Path

Attribute-based routing is purely additive. Existing applications can:

  1. Continue using routes.php exclusively
  2. Gradually migrate routes to attributes
  3. Mix both approaches (traditional routes + attribute routes)
// Hybrid approach in routes.php
return function (RouteBuilder $routes): void {
    // Attribute routes
    $routes->attributes();

    // Keep traditional routes for legacy controllers
    $routes->scope('/legacy', function (RouteBuilder $routes) {
        $routes->fallbacks();
    });
};

Implementation Considerations

  • Caching: Attribute routes are parsed once and cached via the Attribute Resolver
  • Performance: Cached routes have identical performance to traditional routes
  • Validation: Route conflicts are detected at cache warmup time
  • Testing: IntegrationTestCase works identically with attribute routes

References

CakePHP Version

6.0

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions