Routing Reference

Overview

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.

Quick Start

Basic Route with Attribute

<?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 /.

Attribute-Based Routing

HTTP Method Attributes

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');
    }
}

Route Names

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'
}

Route Filters

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:

Route Groups

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:

Multiple Routes on Same Method

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
}

Route Parameters

Single Parameter

#[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);
}

Multiple Parameters

#[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

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:

URL matching examples:

Route: /docs/*path

URL Match Captured Value
/docs/authentication authentication
/docs/cms/guides/auth cms/guides/auth
/docs No match

Parameter Validation

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');
}

Configuration (routing.yaml)

The config/routing.yaml file configures URL rewrites and controller scanning paths.

Basic Configuration

# 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

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

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.

Backward Compatibility

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.

Duplicate Route Detection

The router includes strict duplicate detection to catch configuration errors early.

What Gets Detected

Duplicate Method + Path

// ❌ 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.

Duplicate Route Names

// ❌ 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.

What's Allowed

Different HTTP Methods (RESTful)

// ✅ 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 { }

Multiple Attributes (Aliases)

// ✅ 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
}

Disabling Strict Mode

Strict mode is enabled by default. To disable (not recommended):

$router = Router::instance();
$router->setStrictMode(false);  // Allow duplicates (first match wins)

Route Filters

Filters provide pre and post-processing for routes.

Built-in Filters

Authentication Filter (auth)

Requires admin authentication. Redirects to login if not authenticated.

#[Get('/admin/dashboard', filters: ['auth'])]
public function dashboard(Request $request): string { }

Member Authentication Filter (member)

Requires member (subscriber/editor/author) authentication.

#[Get('/member/dashboard', filters: ['member'])]
public function dashboard(Request $request): string { }

CSRF Filter (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 Filters (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

Custom Filters

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 { }

Named Routes and URL Generation

Generating URLs

In Controllers

#[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']);
}

In Views

<?php
$router = Registry::getInstance()->get('Router');
$url = $router->generateUrl('posts.show', ['slug' => $post->getSlug()]);
?>

<a href="<?= htmlspecialchars($url) ?>">
    <?= htmlspecialchars($post->getTitle()) ?>
</a>

Parameter Substitution

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

Query Parameters

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

Common Routing Patterns

RESTful Resource Routes

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');
    }
}

Nested Resources

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
    }
}

API Versioning with Groups

#[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 { }
}

Wildcard for Documentation/Files

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);
    }
}

Default CMS Routes

The CMS provides these pre-configured routes via controller attributes:

Authentication

Password Reset

Admin Panel

Member Area

Public Blog

Troubleshooting

Route Not Found (404)

Symptom: Accessing a URL returns 404 error

Solutions:

  1. Check controller is scanned:

    # config/routing.yaml
    controller_paths:
      - path: 'app/Controllers'
        namespace: 'App\Controllers'
    
  2. Verify attribute syntax:

    // Correct
    #[Get('/posts/:id')]
    
    // Incorrect
    #[Get('posts/:id')]  // Missing leading slash
    
  3. Check namespace matches:

    namespace App\Controllers;  // Must match controller_paths
    
    class Posts extends Controller { }
    
  4. Verify autoloading:

    composer dump-autoload
    

Duplicate Route Error

Symptom: Application fails to start with duplicate route exception

Solutions:

  1. Check for duplicate paths:

    // Find duplicate by searching for the route path
    grep -r "Get('/users')" src/
    
  2. Use different route names:

    #[Get('/users', name: 'users.index')]
    #[Get('/users', name: 'users.list')]  // Different name
    
  3. Use different HTTP methods:

    #[Get('/users')]    // GET
    #[Post('/users')]   // POST - allowed
    

Controller Not Found

Symptom: "Class not found" error

Solutions:

  1. Check file location matches namespace:

    src/Controllers/Posts.php
    namespace: App\Controllers
    
  2. Verify composer.json autoload:

    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
    
  3. Run composer dump-autoload:

    composer dump-autoload
    

Parameter Not Capturing

Symptom: Route parameter is null

Solutions:

  1. Check parameter syntax:

    // Correct
    #[Get('/posts/:id')]
    
    // Incorrect
    #[Get('/posts/{id}')]  // Wrong syntax
    
  2. 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
    }
    

Filter Not Executing

Symptom: Filter doesn't run or redirect

Solutions:

  1. Check filter is registered:

    $router->registerFilter('auth', new AuthenticationFilter());
    
  2. Check filter name in attribute:

    #[Get('/admin', filters: ['auth'])]  // Must match registered name
    
  3. Verify filter returns correctly:

    public function pre(RouteMap $route): mixed
    {
        if (!$authenticated) {
            return $this->redirect('/login');  // Stop execution
        }
    
        return null;  // Continue to controller
    }
    

Best Practices

Route Organization

  1. Group related controllers: Place admin, member, and public controllers in separate directories
  2. Use consistent naming: Follow resource.action pattern (e.g., posts.index, posts.create)
  3. Document complex routes: Add comments for non-obvious routing logic
  4. Use route groups: Apply common settings at controller level

Performance

  1. Limit controller scanning: Only include necessary directories in controller_paths
  2. Use specific routes: Avoid overly broad wildcard routes
  3. Apply filters selectively: Only to routes that need them
  4. Cache route definitions: Router caches RouteMap objects in memory

Security

  1. Always use CSRF filter: For POST/PUT/DELETE requests
  2. Protect admin routes: Use auth filter
  3. Validate parameters: Never trust user input from URL
  4. Use HTTPS in production: Set secure cookie flags

Maintainability

  1. Use named routes: For URL generation
  2. Keep methods focused: One route per method
  3. Version APIs: Use route groups with /api/v1 prefix
  4. Test routes: Verify all parameters and filters work

Additional Resources