Router Internals
How the router matches URLs and executes controllers under the hood
Inside the Router
Now let's look at what happens when a request comes in. The router needs to:
- Find a matching route
- Extract parameters from the URL
- Run middleware
- Execute the controller
Think of it like a postal service:
- Letter arrives with an address
- Sort through all addresses to find a match
- Check if delivery is allowed (middleware)
- Deliver to the right mailbox (controller)
The Routes Array
Inside the Router class, routes are stored in an array:
protected $routes = [];Each time you call $router->get(), a new route is added:
public function add($method, $uri, $controller)
{
$this->routes[] = [
'uri' => $uri,
'controller' => $controller,
'method' => $method,
'middleware' => null,
];
return $this;
}After registering routes, the array looks like:
[
[
'uri' => '/',
'controller' => 'welcome.php',
'method' => 'GET',
'middleware' => null
],
[
'uri' => '/posts/{id}',
'controller' => 'posts/show.php',
'method' => 'GET',
'middleware' => null
],
[
'uri' => '/dashboard',
'controller' => 'dashboard/index.php',
'method' => 'GET',
'middleware' => 'auth'
]
]The Main Routing Method
When a request comes in, route() is called:
public function route($uri, $method, ?Request $request = null)
{
$this->request = $request;
foreach ($this->routes as $route) {
// Try to match this route...
}
abort(404); // No match found
}Step-by-Step Flow
1. Store the request
$this->request = $request;Why? So we can access it later (for things like "previous URL").
2. Loop through all routes
foreach ($this->routes as $route) {The router checks each registered route one by one.
3. Check HTTP method
if (strtoupper($method) !== $route['method']) {
continue;
}If the request is POST but the route is GET, skip it.
4. Try to match the URI
$params = $this->matchUri($route['uri'], $uri);
if ($params === false) {
continue;
}This is where the magic happens. More on this below.
5. Run middleware
Middleware::resolve($route['middleware']);If middleware fails (like auth check), it stops here.
6. Inject parameters into $_GET
foreach ($params as $key => $value) {
$_GET[$key] = $value;
}So controllers can access $_GET['id'].
7. Find and run the controller
$controllerPath = base_path('app/Http/controllers/' . $route['controller']);
if (!file_exists($controllerPath) && is_dir(base_path('.dalt'))) {
$controllerPath = base_path('.dalt/Http/controllers/' . $route['controller']);
}
if (!file_exists($controllerPath)) {
throw new \RuntimeException("Controller not found: {$route['controller']}");
}
return require $controllerPath;The fallback logic:
- Try app controllers first
- If not found and
.daltexists, try platform controllers - If still not found, throw error
URL Matching: The Heart of Routing
The matchUri() method is where route patterns become reality.
Exact Match (Fast Path)
if ($pattern === $actual) {
return [];
}If there are no parameters and it's an exact match, return immediately.
Example:
Pattern: /about
Actual: /about
Result: [] (empty params, exact match)Pattern with Parameters
For routes like /posts/{id}, we need regex.
Step 1: Convert pattern to regex
$paramNames = [];
$regex = preg_replace_callback(
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
function ($matches) use (&$paramNames) {
$paramNames[] = $matches[1];
return '([^/]+)';
},
$pattern
);What this does:
Pattern: /posts/{id}/edit
↓
Regex: /posts/([^/]+)/edit
Params: ['id']Breaking it down:
/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/- Finds{id},{userId}, etc.$matches[1]- Captures the name inside{}$paramNames[] = $matches[1]- Remembers the namereturn '([^/]+)'- Replaces{id}with "match anything except/"
Step 2: Wrap in anchors
$regex = '#^' . $regex . '$#';Result: #^/posts/([^/]+)/edit$#
^- Must start here$- Must end here#- Delimiter (could be/but#avoids escaping)
Step 3: Test the URL
if (preg_match($regex, $actual, $matches)) {
array_shift($matches); // Remove full match
return array_combine($paramNames, $matches) ?: [];
}Example:
Pattern: /posts/{id}/edit
Actual: /posts/42/edit
Regex: #^/posts/([^/]+)/edit$#
Matches: ['/posts/42/edit', '42']
↓ array_shift removes first
['42']
↓ array_combine with ['id']
['id' => '42']Step 4: Return false if no match
return false;If the URL doesn't match, return false so the router continues to the next route.
Understanding Regex Patterns
Let's break down ([^/]+):
( - Start capturing group
[^/] - Match any character EXCEPT /
+ - One or more times
) - End capturing group
Why [^/]+ and not .*?
Pattern: /posts/{id}
URL: /posts/42/edit
With .*: Matches! id = "42/edit" (too greedy)
With [^/]+: Doesn't match (stops at /)[^/]+ ensures parameters don't span multiple URL segments.
Multiple Parameters
$router->get('/users/{userId}/posts/{postId}', 'posts/show.php');How it works:
Pattern: /users/{userId}/posts/{postId}
↓
Regex: #^/users/([^/]+)/posts/([^/]+)$#
Params: ['userId', 'postId']
URL: /users/5/posts/10
Matches: ['/users/5/posts/10', '5', '10']
↓
Result: ['userId' => '5', 'postId' => '10']Controller Resolution
After finding a match, the router needs to run the controller.
The Fallback Chain
$controllerPath = base_path('app/Http/controllers/' . $route['controller']);
if (!file_exists($controllerPath) && is_dir(base_path('.dalt'))) {
$controllerPath = base_path('.dalt/Http/controllers/' . $route['controller']);
}Priority:
- App controllers (
app/Http/controllers/) - Platform controllers (
.dalt/Http/controllers/) - only if.daltexists
Why this order?
- Your app should be able to override platform controllers
- Platform is a fallback, not a replacement
Running the Controller
return require $controllerPath;What require does:
- Executes the PHP file
- Returns whatever the file returns
- Variables in scope are available to the controller
Example controller:
// posts/show.php
$id = $_GET['id'];
$post = $db->query('SELECT * FROM posts WHERE id = :id', ['id' => $id])
->findOrFail();
view('posts/show.view.php', ['post' => $post]);The controller doesn't return anything explicitly, but view() outputs HTML.
The 404 Handler
If no routes match, it falls back to:
abort(404);What this does:
- Calls the globally defined
abort()helper fromfunctions.php. - Sets the HTTP status code (404, 403, etc.).
- Throws an
HttpExceptionwhich is caught bypublic/index.php.
Why delegate to a global helper?
- To enforce DRY (Don't Repeat Yourself). The framework can abort from anywhere (Controllers, Middleware, or the Router itself) using the same exact function and logic.
Key Takeaways
- Routes are stored in an array - Simple data structure
- Matching is sequential - First match wins (order matters!)
- Regex converts patterns -
{id}becomes([^/]+) - Parameters are captured - Extracted and injected into
$_GET - Controllers are just PHP files -
requireexecutes them
What's Good Here
✅ Simple and understandable implementation
✅ Regex is generated, not hand-written
✅ Fallback to platform controllers is clean
✅ Fast path for exact matches
✅ Clear enough to modify and extend yourself
Design Note
Performance optimizations like route caching, parameter validation, and O(1) lookups are intentionally omitted. For a learning framework with dozens of routes, sequential matching is fast enough. These optimizations would add complexity that obscures how routing fundamentally works.
Section 2 Complete!
You now understand routing:
- How routes are registered
- How URLs are matched to controllers
- How middleware protects routes
- How parameters are extracted
Next, explore the container and database systems.