The CMS routing system uses PHP 8+ attribute-based routing, where routes are defined directly on controller methods using PHP attributes. This provides a modern, type-safe approach with co-located route definitions and excellent IDE support.
The system also supports URL rewrites for transparent URL rewriting before route matching, and duplicate route detection to catch configuration errors early.
<?php
namespace App\Controllers;
use Neuron\Mvc\Controller;
use Neuron\Routing\Attributes\Get;
use Neuron\Mvc\Request;
class Home extends Controller
{
#[Get('/', name: 'home')]
public function index(Request $request): string
{
return $this->renderHtml(OK, [], 'home/index');
}
}
That's it! The #[Get] attribute defines a route that responds to GET requests at /.
Define routes using HTTP method attributes:
use Neuron\Routing\Attributes\{Get, Post, Put, Delete};
class PostsController extends Controller
{
#[Get('/posts')]
public function index(Request $request): string
{
// List all posts
}
#[Get('/posts/:id')]
public function show(Request $request): string
{
// Show single post
$id = $request->getRouteParameter('id');
}
#[Post('/posts')]
public function store(Request $request): never
{
// Create new post
$this->redirect('posts.index');
}
#[Put('/posts/:id')]
public function update(Request $request): never
{
// Update post
$id = $request->getRouteParameter('id');
$this->redirect('posts.show', ['id' => $id]);
}
#[Delete('/posts/:id')]
public function destroy(Request $request): never
{
// Delete post
$this->redirect('posts.index');
}
}
Named routes enable URL generation without hardcoding paths:
#[Get('/blog/post/:slug', name: 'blog.show')]
public function show(Request $request): string
{
// Generate URL to this route
$url = $this->getRouter()->generateUrl('blog.show', [
'slug' => 'my-first-post'
]);
// $url = '/blog/post/my-first-post'
}
Apply filters for authentication, CSRF protection, and rate limiting:
// Single filter
#[Get('/admin/dashboard', filters: ['auth'])]
public function dashboard(Request $request): string { }
// Multiple filters
#[Post('/admin/users', filters: ['auth', 'csrf'])]
public function store(Request $request): never { }
Built-in filters:
auth - Requires admin authenticationmember - Requires member (subscriber/editor/author) authenticationcsrf - Validates CSRF token (for POST/PUT/DELETE)rate_limit - Basic rate limitingapi_limit - API rate limiting (higher limits)Apply common settings to all routes in a controller:
use Neuron\Routing\Attributes\RouteGroup;
#[RouteGroup(prefix: '/admin', filters: ['auth'])]
class AdminController extends Controller
{
#[Get('/dashboard')] // Becomes /admin/dashboard with 'auth' filter
public function dashboard(Request $request): string { }
#[Post('/users', filters: ['csrf'])] // Becomes /admin/users with ['auth', 'csrf']
public function createUser(Request $request): never { }
}
Route group parameters:
prefix - Path prefix for all routes in controllerfilters - Filters applied to all routes (can be extended per-route)Useful for API versioning or URL aliasing:
#[Get('/api/v1/users')]
#[Get('/api/v2/users')]
public function getUsers(Request $request): string
{
// Handle both API versions
}
// Backward compatibility
#[Get('/user/:id')]
#[Get('/profile/:id')]
#[Get('/member/:id')]
public function show(Request $request): string
{
// Support old and new URLs
}
#[Get('/posts/:id')]
public function show(Request $request): string
{
$id = $request->getRouteParameter('id');
// Validate and typecast
if (!is_numeric($id)) {
throw new NotFound('Invalid ID format');
}
$id = (int) $id;
$post = $this->postRepository->findById($id);
}
#[Get('/users/:userId/posts/:postId')]
public function show(Request $request): string
{
$userId = $request->getRouteParameter('userId');
$postId = $request->getRouteParameter('postId');
// Or get all at once
$params = $request->getRouteParameters();
// ['userId' => '123', 'postId' => '456']
}
Wildcard parameters (prefixed with *) capture all remaining URL segments including slashes:
#[Get('/docs/*path')]
public function show(Request $request): string
{
$path = $request->getRouteParameter('path');
// URL: /docs/cms/guides/authentication
// $path = 'cms/guides/authentication'
$file = $this->basePath . '/' . $path . '.md';
return $this->renderMarkdown($file);
}
Wildcard characteristics:
/) in captured valueURL matching examples:
Route: /docs/*path
| URL | Match | Captured Value |
|---|---|---|
/docs/authentication |
✓ | authentication |
/docs/cms/guides/auth |
✓ | cms/guides/auth |
/docs |
✗ | No match |
Always validate and typecast route parameters:
#[Get('/posts/:id')]
public function show(Request $request): string
{
$id = $request->getRouteParameter('id');
// Validate numeric ID
if (!is_numeric($id)) {
throw new NotFound('Invalid post ID');
}
$id = (int) $id;
// Validate range
if ($id <= 0) {
throw new NotFound('Invalid post ID');
}
$post = $this->postRepository->findById($id);
if (!$post) {
throw new NotFound('Post not found');
}
return $this->renderHtml(OK, ['post' => $post], 'posts/show');
}
The config/routing.yaml file configures URL rewrites and controller scanning paths.
# config/routing.yaml
# URL Rewrites (applied before route matching)
rewrites:
'/': '/blog' # Root goes to blog
'/index': '/blog' # Legacy URL support
# Controller Paths (scanned for route attributes)
controller_paths:
- path: 'app/Controllers'
namespace: 'App\Controllers'
- path: 'vendor/neuron-php/cms/src/Cms/Controllers'
namespace: 'Neuron\Cms\Controllers'
URL rewrites provide transparent URL rewriting before route matching. Unlike HTTP redirects, rewrites are internal and invisible to the client.
Use cases:
Example: Custom Homepage
# config/routing.yaml
rewrites:
'/': '/custom/landing' # Rewrite root to custom controller
controller_paths:
- path: 'app/Controllers' # Your controllers first
namespace: 'App\Controllers'
- path: 'vendor/neuron-php/cms/src/Cms/Controllers'
namespace: 'Neuron\Cms\Controllers'
// app/Controllers/Landing.php
class Landing extends Controller
{
#[Get('/custom/landing', name: 'landing')]
public function index(Request $request): string
{
return $this->renderHtml(OK, [], 'custom-home');
}
}
Now requests to / are transparently routed to your custom landing page without any HTTP redirect.
Rewrite characteristics:
Controller paths define directories to scan for route attributes. Order matters - controllers listed first take precedence.
controller_paths:
# Your application controllers (checked first)
- path: 'app/Controllers'
namespace: 'App\Controllers'
# CMS controllers (checked second)
- path: 'vendor/neuron-php/cms/src/Cms/Controllers'
namespace: 'Neuron\Cms\Controllers'
# Plugin controllers (checked last)
- path: 'plugins/Forum/Controllers'
namespace: 'Plugins\Forum\Controllers'
This allows your application to override package routes by defining the same route path in your controllers.
If config/routing.yaml doesn't exist, the system falls back to config/neuron.yaml:
# config/neuron.yaml (legacy)
routing:
controller_paths:
- path: 'src/Controllers'
namespace: 'App\Controllers'
Recommendation: Use config/routing.yaml for new projects.
The router includes strict duplicate detection to catch configuration errors early.
// ❌ ERROR: Duplicate route
class UsersController extends Controller
{
#[Get('/users')]
public function index(Request $request): string { }
#[Get('/users')] // Same method + path
public function list(Request $request): string { }
}
Error message:
Duplicate route detected: GET /users
First: App\Controllers\UsersController@index
Second: App\Controllers\UsersController@list
Suggestion: Use different paths, different HTTP methods, or combine into one method.
// ❌ ERROR: Duplicate name
class UsersController extends Controller
{
#[Get('/users', name: 'users')]
public function index(Request $request): string { }
#[Post('/users/create', name: 'users')] // Same name
public function store(Request $request): never { }
}
Error message:
Duplicate route name detected: 'users'
First: GET /users → App\Controllers\UsersController@index
Second: POST /users/create → App\Controllers\UsersController@store
Suggestion: Use different route names or remove one of the routes.
// ✅ ALLOWED: Same path, different methods
#[Get('/users', name: 'users.index')]
public function index(Request $request): string { }
#[Post('/users', name: 'users.store')]
public function store(Request $request): never { }
// ✅ ALLOWED: Multiple routes to same handler
#[Get('/user/:id')]
#[Get('/profile/:id')]
#[Get('/member/:id')]
public function show(Request $request): string
{
// Backward compatibility or URL aliasing
}
Strict mode is enabled by default. To disable (not recommended):
$router = Router::instance();
$router->setStrictMode(false); // Allow duplicates (first match wins)
Filters provide pre and post-processing for routes.
auth)Requires admin authentication. Redirects to login if not authenticated.
#[Get('/admin/dashboard', filters: ['auth'])]
public function dashboard(Request $request): string { }
member)Requires member (subscriber/editor/author) authentication.
#[Get('/member/dashboard', filters: ['member'])]
public function dashboard(Request $request): string { }
csrf)Validates CSRF token for state-changing requests (POST, PUT, DELETE).
#[Post('/posts', filters: ['auth', 'csrf'])]
public function store(Request $request): never { }
Automatically validates csrf_token field in request data.
rate_limit, api_limit)Limits request rate per IP address.
#[Get('/api/v1/users', filters: ['api_limit'])]
public function index(Request $request): string { }
Configuration in config/neuron.yaml:
rate_limit:
enabled: true
storage: redis # or 'file'
requests: 60
window: 60 # seconds
api_limit:
enabled: true
storage: redis
requests: 1000
window: 3600 # 1 hour
Create custom filters by extending Neuron\Routing\Filter:
namespace App\Filters;
use Neuron\Routing\Filter;
use Neuron\Routing\RouteMap;
use Neuron\Log\Log;
class AdminOnlyFilter extends Filter
{
public function pre(RouteMap $route): mixed
{
$user = auth();
if (!$user || !$user->isAdmin()) {
http_response_code(403);
return 'Access denied';
}
// Return null to continue
return null;
}
public function post(RouteMap $route): void
{
Log::info('Admin action: ' . $route->getPath());
}
}
Register filter:
// In application initializer
$router = Router::instance();
$router->registerFilter('admin_only', new AdminOnlyFilter());
Use in route:
#[Get('/admin/users', filters: ['admin_only'])]
public function index(Request $request): string { }
#[Get('/posts/:slug', name: 'posts.show')]
public function show(Request $request): string
{
// Generate relative URL
$url = $this->getRouter()->generateUrl('posts.show', [
'slug' => 'my-first-post'
]);
// $url = '/posts/my-first-post'
// Generate absolute URL
$absoluteUrl = $this->getRouter()->generateUrl('posts.show', [
'slug' => 'my-first-post'
], true);
// $absoluteUrl = 'https://example.com/posts/my-first-post'
// Redirect to named route
$this->redirect('posts.show', ['slug' => 'my-first-post']);
}
<?php
$router = Registry::getInstance()->get('Router');
$url = $router->generateUrl('posts.show', ['slug' => $post->getSlug()]);
?>
<a href="<?= htmlspecialchars($url) ?>">
<?= htmlspecialchars($post->getTitle()) ?>
</a>
Parameters in route path are automatically replaced:
#[Get('/users/:username/profile', name: 'user.profile')]
public function profile(Request $request): string { }
// Generate URL
$url = $router->generateUrl('user.profile', [
'username' => 'john_doe'
]);
// Result: /users/john_doe/profile
Add query parameters manually:
$url = $router->generateUrl('posts.show', ['slug' => 'my-post']);
$url .= '?page=2&sort=date';
// Result: /posts/my-post?page=2&sort=date
class PostsController extends Controller
{
#[Get('/posts', name: 'posts.index')]
public function index(Request $request): string
{
// List all posts
}
#[Get('/posts/create', name: 'posts.create', filters: ['auth'])]
public function create(Request $request): string
{
// Show create form
}
#[Post('/posts', name: 'posts.store', filters: ['auth', 'csrf'])]
public function store(Request $request): never
{
// Store new post
$this->redirect('posts.index');
}
#[Get('/posts/:id', name: 'posts.show')]
public function show(Request $request): string
{
// Show single post
}
#[Get('/posts/:id/edit', name: 'posts.edit', filters: ['auth'])]
public function edit(Request $request): string
{
// Show edit form
}
#[Put('/posts/:id', name: 'posts.update', filters: ['auth', 'csrf'])]
public function update(Request $request): never
{
// Update post
$id = $request->getRouteParameter('id');
$this->redirect('posts.show', ['id' => $id]);
}
#[Delete('/posts/:id', name: 'posts.destroy', filters: ['auth', 'csrf'])]
public function destroy(Request $request): never
{
// Delete post
$this->redirect('posts.index');
}
}
class UserPostsController extends Controller
{
#[Get('/users/:userId/posts', name: 'users.posts.index')]
public function index(Request $request): string
{
$userId = $request->getRouteParameter('userId');
// List user's posts
}
#[Get('/users/:userId/posts/:postId', name: 'users.posts.show')]
public function show(Request $request): string
{
$userId = $request->getRouteParameter('userId');
$postId = $request->getRouteParameter('postId');
// Show user's post
}
}
#[RouteGroup(prefix: '/api/v1', filters: ['api_limit'])]
class ApiV1Controller extends Controller
{
#[Get('/users')]
public function users(Request $request): string { }
#[Get('/posts')]
public function posts(Request $request): string { }
}
#[RouteGroup(prefix: '/api/v2', filters: ['api_limit'])]
class ApiV2Controller extends Controller
{
#[Get('/users')]
public function users(Request $request): string { }
#[Get('/posts')]
public function posts(Request $request): string { }
}
class DocsController extends Controller
{
#[Get('/docs/*path', name: 'docs.show')]
public function show(Request $request): string
{
$path = $request->getRouteParameter('path');
// Validate path (prevent directory traversal)
if (str_contains($path, '..')) {
throw new NotFound('Invalid path');
}
$file = $this->basePath . '/' . $path . '.md';
if (!file_exists($file)) {
throw new NotFound('Documentation page not found');
}
return $this->renderMarkdown($file);
}
}
The CMS provides these pre-configured routes via controller attributes:
GET /login - Login formPOST /login - Process loginPOST /logout - Logout (auth filter)GET /forgot-password - Forgot password formPOST /forgot-password - Request resetGET /reset-password - Reset formPOST /reset-password - Process resetGET /admin - Admin dashboard (auth filter)GET /admin/users - User management (auth filter)GET /admin/posts - Post management (auth filter)GET /admin/categories - Category management (auth filter)GET /admin/tags - Tag management (auth filter)GET /register - Registration formPOST /register - Process registrationGET /verify-email - Email verificationPOST /resend-verification - Resend verification (rate-limited)GET /member - Member dashboard (member filter)GET /member/profile - Profile management (member filter)GET /blog - Blog listingGET /blog/post/:slug - Individual postGET /blog/category/:slug - Category listingGET /blog/tag/:slug - Tag listingGET /blog/author/:username - Author postsGET /blog/rss - RSS feedSymptom: Accessing a URL returns 404 error
Solutions:
Check controller is scanned:
# config/routing.yaml
controller_paths:
- path: 'app/Controllers'
namespace: 'App\Controllers'
Verify attribute syntax:
// Correct
#[Get('/posts/:id')]
// Incorrect
#[Get('posts/:id')] // Missing leading slash
Check namespace matches:
namespace App\Controllers; // Must match controller_paths
class Posts extends Controller { }
Verify autoloading:
composer dump-autoload
Symptom: Application fails to start with duplicate route exception
Solutions:
Check for duplicate paths:
// Find duplicate by searching for the route path
grep -r "Get('/users')" src/
Use different route names:
#[Get('/users', name: 'users.index')]
#[Get('/users', name: 'users.list')] // Different name
Use different HTTP methods:
#[Get('/users')] // GET
#[Post('/users')] // POST - allowed
Symptom: "Class not found" error
Solutions:
Check file location matches namespace:
src/Controllers/Posts.php
namespace: App\Controllers
Verify composer.json autoload:
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
Run composer dump-autoload:
composer dump-autoload
Symptom: Route parameter is null
Solutions:
Check parameter syntax:
// Correct
#[Get('/posts/:id')]
// Incorrect
#[Get('/posts/{id}')] // Wrong syntax
Match parameter name:
#[Get('/posts/:id')]
public function show(Request $request): string
{
// Correct
$id = $request->getRouteParameter('id');
// Incorrect
$id = $request->getRouteParameter('post_id'); // Wrong name
}
Symptom: Filter doesn't run or redirect
Solutions:
Check filter is registered:
$router->registerFilter('auth', new AuthenticationFilter());
Check filter name in attribute:
#[Get('/admin', filters: ['auth'])] // Must match registered name
Verify filter returns correctly:
public function pre(RouteMap $route): mixed
{
if (!$authenticated) {
return $this->redirect('/login'); // Stop execution
}
return null; // Continue to controller
}
resource.action pattern (e.g., posts.index, posts.create)controller_pathsauth filter/api/v1 prefix