Einfaches und elegantes URL-Routing mit PHP

Einfaches und elegantes URL-Routing mit PHP

Wenn man kleinere PHP-Projekte umsetzen möchte und sich gegen große Frameworks entscheidet steht irgendwann die Frage nach URL-Routing im Raum. Wie führt also die Eingabe einer bestimmten URL dazu, dass bestimmte Inhalte ausgegeben werden? Ich will hier eine einfache und sehr elegante Möglichkeit beschreiben, die suchmaschinenfreundliche URLS mit Hilfe von RegExp verarbeitet.

Update: Da dieser Post zu einem der meistgelesenen auf meinem Blog zählt habe ich dem Code ein kompletes Refacturing spendiert. Den kompletten Code findest du auch auf Github: https://github.com/steampixel/simplePHPRouter/tree/master

Als ich vor vielen Jahren begann mit PHP zu arbeiten wurden Seitenaufrufe oft über das GET-Array gesteuert. Oft sah man URLs, denen viele Parameter wie index.php?page=5&action=delete&return_to_page=3 angehangen wurden. Die Frontcontroller sahen meist so aus:

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

Diese Methode wird schnell unübersichtlich und man verliert sich in Paramern und If-Bedingungen. Das Resultat ist Spaghetti-Code. Zudem sind die URLs nur schwer verständlich und auch nicht gerade leicht zu merken. Mit Hilfe von PHP's anonymen Funktionen und RegExp lassen sich jedoch leicht zu konfigurierende, suchmaschinenfreundliche und elegante Routen erstellen. Eine beispielhafte index.php könnte dann so aussehen:

// 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('/');

Hier können beliebige Routen angelegt werden. Die Parameter können gleich per RegExp aus der Route geparst und an den Handler übergeben werden. Zusätzlich kannst du kontrollieren mit welchen Methoden (get, post, put, patch, etc...) auf die Routen zugegriffen werden darf. Die Handler werden nur ausgeführt, wenn die Routen auch mit der eingegebenen Pfad übereinstimmen.

Aufbau der Route.php-Klasse:

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));
        }
      }

    }

  }

}

Wie du sehen kannst besteht der Router aus weniger Code, als du eventuell dachtest. Erst, wenn die Methode run() aufgerufen wird, werden die einzelnen Routen, die mit add() registriert wurden überprüft und gegebenenfalls ausgeführt. Beim Ausführen wird immer erst der Basispfad, in dem das Projekt läuft vor das Pattern gestellt und so mit berücksichtigt. Wird keine Übereinstimmung gefunden wird automatisch die 404-Route bzw. die 405-Route ausgeführt.

Damit alles funktioniert müssen natürlich alle Requests mit einem Rewrite an die index.php weitergeleitet werden. Das erreicht man beim Apache2 Webserver mit einer .htaccess Datei:

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]

Dieser URL-Router ist natürlich noch stark ausbaufähig, kann aber schon eine solide Basis für kleinere Projekte bilden.