Simple and elegant URL routing with PHP

If you want to implement smaller PHP projects and the decision goes against large frameworks, the question of URL routing will eventually come up. How does one particular URL cause a certain content to be outputted? I want to describe a simple and very elegant way of processing search engine friendly URLs using RegExp and PHP's anonymous functions.

Update: Since this post counts to one of the most read on this blog I have given the code a complete refactoring. You can also find the complete code on Github: https://github.com/steampixel/simplePHPRouter/tree/master

When I started working with PHP many years ago, pages were often generated by using the GET, POST or REQUEST variables and complex if statements. Often you could see URLs with many parameters like index.php?page=5&action=delete&return_to_page=3. The front controllers usually looked like this:

if(isset($_REQUEST['page_id'])){
	
    if($_REQUEST['page_id']==3){
		// load page
	}
 
	if($_REQUEST['action']=='delete'){
		// do something
	}
 
	// do more complex things
	
}

This method quickly becomes confusing and you lose yourself in parameters and if statements. The result is spaghetti code. In addition, the URLs are difficult to understand and not easy to remember. However, anonymous functions and RegExp make it easy to configure, search engine friendly, and elegant routes. An example index.php could look like this:

// Include router class
include('Route.php');

// Add base route (startpage)
Route::add('/',function(){
    echo 'Welcome :-)';
});

// Simple test route that simulates static html file
Route::add('/test.html',function(){
    echo 'Hello from test.html';
});

// Post route example
Route::add('/contact-form',function(){
    echo '<form method="post"><input type="text" name="test" /><input type="submit" value="send" /></form>';
},'get');

// Post route example
Route::add('/contact-form',function(){
    echo 'Hey! The form has been sent:<br/>';
    print_r($_POST);
},'post');

// Accept only numbers as parameter. Other characters will result in a 404 error
Route::add('/foo/([0-9]*)/bar',function($var1){
    echo $var1.' is a great number!';
});

Route::run('/');

Any routes can be created here. The parameters can be parsed from the route via RegExp and passed to the handler. In addition, you can control through which methods (get, post, put, patch, etc ...) the routes may be accessed. The handlers gets only executed if the routes match the entered path.

Structure of the Route.php class:

class Route{

  private static $routes = Array();
  private static $pathNotFound = null;
  private static $methodNotAllowed = null;

  public static function add($expression, $function, $method = 'get'){
    array_push(self::$routes,Array(
      'expression' => $expression,
      'function' => $function,
      'method' => $method
    ));
  }

  public static function pathNotFound($function){
    self::$pathNotFound = $function;
  }

  public static function methodNotAllowed($function){
    self::$methodNotAllowed = $function;
  }

  public static function run($basepath = '/'){

    // Parse current url
    $parsed_url = parse_url($_SERVER['REQUEST_URI']);//Parse Uri

    if(isset($parsed_url['path'])){
      $path = $parsed_url['path'];
    }else{
      $path = '/';
    }

    // Get current request method
    $method = $_SERVER['REQUEST_METHOD'];

    $path_match_found = false;

    $route_match_found = false;

    foreach(self::$routes as $route){

      // If the method matches check the path

      // Add basepath to matching string
      if($basepath!=''&&$basepath!='/'){
        $route['expression'] = '('.$basepath.')'.$route['expression'];
      }

      // Add 'find string start' automatically
      $route['expression'] = '^'.$route['expression'];

      // Add 'find string end' automatically
      $route['expression'] = $route['expression'].'$';

      // echo $route['expression'].'<br/>';

      // Check path match	
      if(preg_match('#'.$route['expression'].'#',$path,$matches)){

        $path_match_found = true;

        // Check method match
        if(strtolower($method) == strtolower($route['method'])){

          array_shift($matches);// Always remove first element. This contains the whole string

          if($basepath!=''&&$basepath!='/'){
            array_shift($matches);// Remove basepath
          }

          call_user_func_array($route['function'], $matches);

          $route_match_found = true;

          // Do not check other routes
          break;
        }
      }
    }

    // No matching route was found
    if(!$route_match_found){

      // But a matching path exists
      if($path_match_found){
        header("HTTP/1.0 405 Method Not Allowed");
        if(self::$methodNotAllowed){
          call_user_func_array(self::$methodNotAllowed, Array($path,$method));
        }
      }else{
        header("HTTP/1.0 404 Not Found");
        if(self::$pathNotFound){
          call_user_func_array(self::$pathNotFound, Array($path));
        }
      }

    }

  }

}

As you can see, the router consists of less code than you might think. Only if the run() method gets executed, the individual routes registered with add() will be checked and executed. When running, the base path in which the project lives in, is always placed before the pattern and thus taken into account. If no match is found, the 404 route or the 405 route is automatically triggered.

Of course all requests have to be forwarded to index.php with a rewrite. This can be reached on Apache with a .htaccess file:

DirectoryIndex index.php

# enable apache rewrite engine
RewriteEngine on

# set your rewrite base
# Edit this in your init method too if you script lives in a subfolder
RewriteBase /

# Deliver the folder or file directly if it exists on the server
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
 
# Push every request to index.php
RewriteRule ^(.*)$ index.php [QSA]

This URL router is still very expandable, but it can already provide a solid basis for smaller projects.

You can find the source on Github: https://github.com/steampixel/simplePHPRouter/tree/master