DALT.PHP
Framework Deep Dive2. Routing

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:

  1. Find a matching route
  2. Extract parameters from the URL
  3. Run middleware
  4. 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 .dalt exists, 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 name
  • return '([^/]+)' - 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:

  1. App controllers (app/Http/controllers/)
  2. Platform controllers (.dalt/Http/controllers/) - only if .dalt exists

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:

  1. Calls the globally defined abort() helper from functions.php.
  2. Sets the HTTP status code (404, 403, etc.).
  3. Throws an HttpException which is caught by public/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

  1. Routes are stored in an array - Simple data structure
  2. Matching is sequential - First match wins (order matters!)
  3. Regex converts patterns - {id} becomes ([^/]+)
  4. Parameters are captured - Extracted and injected into $_GET
  5. Controllers are just PHP files - require executes 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.

On this page