-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Description
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
- Full Feature Parity: Support all existing RouteBuilder capabilities
- Opt-in: Completely optional—existing routes.php continues to work
- Composable: Class-level and method-level attributes combine intuitively
- Cacheable: Integrates with the new Attribute Resolver's caching system
- 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
routeClassparameter 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 orderdefaults: 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:
- Position in routes.php: Routes connected before
$routes->attributes()match first - Attribute route order: Deterministic ordering by class name (alphabetical), then method definition order
- 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:
- Continue using
routes.phpexclusively - Gradually migrate routes to attributes
- 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:
IntegrationTestCaseworks identically with attribute routes
References
CakePHP Version
6.0