v1.0
Docs / Routing System

Routing System

This page explains how attribute-based routing works, from route scanning to caching and debugging. Includes step-by-step examples and Docker commands.

Table of Contents

  1. How It Works
  2. RouteScanner (Detailed)
  3. Route Caching
  4. URL Parameters
  5. Debugging Routes
  6. Docker Verification

2. How Auto-Discovery Works

Task: Verify that the `RouteScanner` is finding your routes by running `php spark routes`.

Technical Concept

Instead of a central Routes.php file, routes are declared directly on controller methods using #[Route] attributes. The system:

  1. Scans all controllers for #[Route] attributes
  2. Registers routes with CI4's router
  3. Caches results for performance
  4. Invalidates cache when files change

Flow Diagram

Application boots │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Config/Routes.php │ │ │ │ // Near the end of the file: │ │ RouteScanner::scan($routes); │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ RouteScanner::scan() │ │ │ │ 1. Check: Is cache valid? │ │ └── If yes: Load cache, register routes, DONE │ │ └── If no: Continue to step 2 │ │ │ │ 2. Find all PHP files in Modules/*/Controllers/ │ │ │ │ 3. For each file: │ │ a. Use Reflection to read class/methods │ │ b. Find #[Route] and #[Middleware] attributes │ │ c. Register route with CI4 router │ │ │ │ 4. Save to cache file │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Cache File Created │ │ Path: writable/cache/attribute_routes.php │ │ │ │ return [ │ │ 'routes' => [...], │ │ 'timestamp' => 1704326400, │ │ ]; │ └─────────────────────────────────────────────────────────┘

2. RouteScanner (Detailed)

Location

File: app/Core/Routing/RouteScanner.php

Key Methods

<?php

namespace App\Core\Routing;

use CodeIgniter\Router\RouteCollection;

class RouteScanner
{
    /**
     * Main entry point: scan and register routes
     */
    public static function scan(RouteCollection $routes, bool $forceRescan = false): void
    {
        $cache = new RouteCache();
        
        // 1. Check cache
        if (!$forceRescan && $cache->isValid()) {
            $cachedRoutes = $cache->load();
            self::registerFromCache($routes, $cachedRoutes);
            return;
        }
        
        // 2. Scan controllers
        $discoveredRoutes = self::scanControllers();
        
        // 3. Register routes
        foreach ($discoveredRoutes as $routeData) {
            self::registerRoute($routes, $routeData);
        }
        
        // 4. Save cache
        $cache->save($discoveredRoutes);
    }

    /**
     * Scan all controller files
     */
    private static function scanControllers(): array
    {
        $routes = [];
        $files = self::findControllerFiles();
        
        foreach ($files as $file) {
            $className = self::getClassNameFromFile($file);
            
            if ($className === null) {
                continue;
            }
            
            $routes = array_merge(
                $routes,
                self::scanClass($className)
            );
        }
        
        return $routes;
    }

    /**
     * Scan a single class for Route attributes
     */
    private static function scanClass(string $className): array
    {
        $routes = [];
        $reflection = new \ReflectionClass($className);
        
        // Get class-level middleware
        $classMiddleware = self::getMiddleware($reflection->getAttributes());
        
        foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
            $routeAttrs = $method->getAttributes(\App\Attributes\Route::class);
            
            foreach ($routeAttrs as $attr) {
                $route = $attr->newInstance();
                
                // Get method-level middleware
                $methodMiddleware = self::getMiddleware($method->getAttributes());
                
                $routes[] = [
                    'path'       => $route->path,
                    'methods'    => $route->methods,
                    'name'       => $route->name,
                    'handler'    => $className . '::' . $method->getName(),
                    'middleware' => array_merge($classMiddleware, $methodMiddleware),
                ];
            }
        }
        
        return $routes;
    }
}

What Gets Scanned

LocationPatternExample
Module controllersModules/*/Controllers/*.phpModules/Api/Controllers/MetricsController.php
Feature controllersModules/*/*/Controllers/*.phpModules/Admin/User/Controllers/UserController.php

1. Overview

Goal: Understand how the system automatically finds your routes so you don't have to register them manually.

Why Cache?

Scanning files on every request is slow. Without cache:

With cache: <1ms per request.

Cache File Structure

Location: writable/cache/attribute_routes.php

<?php
return [
    'routes' => [
        [
            'path' => '/',
            'methods' => ['GET'],
            'name' => null,
            'handler' => 'App\\Modules\\Web\\Home\\Controllers\\HomeController::index',
            'middleware' => [],
        ],
        [
            'path' => '/admin/dashboard',
            'methods' => ['GET'],
            'name' => 'admin.dashboard',
            'handler' => 'App\\Modules\\Admin\\Dashboard\\Controllers\\DashboardController::index',
            'middleware' => ['auth', 'admin'],
        ],
        // ... more routes
    ],
    'timestamp' => 1704326400,
];

Cache Invalidation

The ModuleChangeDetector checks if any controller file is newer than the cache:

class ModuleChangeDetector
{
    public function hasChanges(): bool
    {
        $cacheFile = WRITEPATH . 'cache/attribute_routes.php';
        
        if (!file_exists($cacheFile)) {
            return true; // No cache = must scan
        }
        
        $cacheTime = filemtime($cacheFile);
        
        // Check each controller file
        foreach ($this->getControllerFiles() as $file) {
            if ($file->getMTime() > $cacheTime) {
                return true; // File changed = rescan
            }
        }
        
        return false; // Cache is valid
    }
}

Force Cache Refresh

# Method 1: Delete cache file
docker-compose exec php rm -f /var/www/html/writable/cache/attribute_routes.php

# Method 2: Force rescan in code
RouteScanner::scan($routes, true);  // true = force

# Method 3: Touch any controller file (updates mtime)
docker-compose exec php touch /var/www/html/app/Modules/Web/Home/Controllers/HomeController.php

4. URL Parameters

Defining Parameters

// Single parameter
#[Route('/users/{id}', methods: ['GET'])]
public function show(int $id)
{
    // $id is extracted from URL
    // /users/42 → $id = 42
}

// Multiple parameters
#[Route('/blog/{year}/{month}/{slug}')]
public function post(int $year, int $month, string $slug)
{
    // /blog/2024/01/my-post
    // $year = 2024, $month = 1, $slug = "my-post"
}

// Optional parameters (with default)
#[Route('/products/{category?}')]
public function list(?string $category = null)
{
    // /products → $category = null
    // /products/electronics → $category = "electronics"
}

Parameter Types

URL PatternMethod SignatureExample URLResult
/users/{id}show(int $id)/users/42$id = 42
/posts/{slug}show(string $slug)/posts/hello-world$slug = "hello-world"
/page/{num?}list(?int $num = 1)/page$num = 1

Named Routes

// Define a named route
#[Route('/users/{id}', methods: ['GET'], name: 'users.show')]
public function show(int $id) { }

// Generate URL from name
$url = route_to('users.show', 42);  // "/users/42"

// In views
<a href="<?= route_to('users.show', $user->getId()) ?>">
    View Profile
</a>

5. Debugging Routes

List All Routes

php spark routes

Expected Output

+--------+---------------------------+----------------------------------------------------+
| Method | Route                     | Handler                                            |
+--------+---------------------------+----------------------------------------------------+
| GET    | /                         | \App\Modules\Web\Home\Controllers\HomeController::index |
| GET    | /about                    | \App\Modules\Web\Home\Controllers\HomeController::about |
| GET    | /admin/dashboard          | \App\Modules\Admin\Dashboard\Controllers\DashboardController::index |
| GET    | /admin/users              | \App\Modules\Admin\User\Controllers\UserController::index |
| GET    | /admin/users/{id}         | \App\Modules\Admin\User\Controllers\UserController::show |
| POST   | /admin/users              | \App\Modules\Admin\User\Controllers\UserController::store |
| GET    | /docs                     | \App\Modules\Web\Docs\Controllers\DocsController::index |
| GET    | /docs/architecture        | \App\Modules\Web\Docs\Controllers\DocsController::architecture |
| GET    | /api/metrics              | \App\Modules\Api\Controllers\MetricsController::index |
+--------+---------------------------+----------------------------------------------------+

Filter by Path

# Show only admin routes
php spark routes | grep admin

# Show only API routes
php spark routes | grep api

Common Issues

ProblemCauseSolution
Route not foundCache is staleDelete cache file
Wrong controllerDuplicate pathCheck for duplicate #[Route] paths
404 errorTypo in pathCheck #[Route] path spelling
Namespace errorFile in wrong folderMatch namespace to folder structure

6. Docker Verification

View All Routes

docker-compose exec php php spark routes

Check Cache File

# View cache file
docker-compose exec php cat /var/www/html/writable/cache/attribute_routes.php

# Check cache timestamp
docker-compose exec php stat /var/www/html/writable/cache/attribute_routes.php

Clear Cache

# Method 1: Use spark command (Recommended)
docker-compose exec php php spark route:clear

# Method 2: Force rescan in code
RouteScanner::scan($routes, true);  // true = force

# Method 3: Delete cache file manually
docker-compose exec php rm -f /var/www/html/writable/cache/attribute_routes.json

Test Route Resolution

# Make a request and check response
curl -I http://localhost:81/admin/dashboard

# Output shows status:
# HTTP/1.1 200 OK  ← Route matched!
# HTTP/1.1 404 Not Found  ← Route not found

Debug Scanning

# Count controller files
docker-compose exec php find /var/www/html/app/Modules -name "*Controller.php" | wc -l

# List all controller files
docker-compose exec php find /var/www/html/app/Modules -name "*Controller.php"
ESC

Start typing to search the documentation