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
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:
- Scans all controllers for
#[Route]attributes - Registers routes with CI4's router
- Caches results for performance
- 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
| Location | Pattern | Example |
|---|---|---|
| Module controllers | Modules/*/Controllers/*.php | Modules/Api/Controllers/MetricsController.php |
| Feature controllers | Modules/*/*/Controllers/*.php | Modules/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:
- Find ~100+ PHP files
- Load each file
- Create Reflection objects
- Parse attributes
- Total: ~50-100ms per request
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.php4. 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 Pattern | Method Signature | Example URL | Result |
|---|---|---|---|
/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 routesExpected 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 apiCommon Issues
| Problem | Cause | Solution |
|---|---|---|
| Route not found | Cache is stale | Delete cache file |
| Wrong controller | Duplicate path | Check for duplicate #[Route] paths |
| 404 error | Typo in path | Check #[Route] path spelling |
| Namespace error | File in wrong folder | Match namespace to folder structure |
6. Docker Verification
View All Routes
docker-compose exec php php spark routesCheck 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.phpClear 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.jsonTest 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 foundDebug 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"