URL routing (in PHP) - The WWW

Users browsing this thread: 1 Guest(s)
eksith
Long time nixers
'Routing' is just a fancy shmancy term for 'a thing requested gets sent to a specific area'. And that's about it. There are other (very heavy) things like acronyms attached to it such as 'MVC', 'MVP', 'KFC' etc... but the gist of it is just routing a particular request to a particular area of your site to process it further.

Before we start, I'm going to make a few assumptions: You've played around with PHP for at least a bit. You know what classes are, how variables are defined and how 'require' works. Preferably, you have more experience.

This is a very basic routing method using 2 directories and 3 files. The first step would be setting your directory structure (well actually, it would be setting up the server, but I'll gloss over that bit :P)
Code:
/webroot
   |
   |-- /libs
   |    |
   |    |-- /controllers
   |    |    |
   |    |    |-- posts.php
   |    |
   |    |-- router.php
   |
   |-- index.php

Now if you've played around with PHP, you know a cool trick to route all your requests to one file; your index.php. Apache is still very popular so in your .htaccess file, you'll likely use something like this.
Code:
RewriteEngine On

RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^(.*)$ index.php/$1 [L,QSA]

Then there's also Nginx, which is my preferred web server, and it handles PHP and other scripting via FastCGI. There are plenty of examples on the web on setting up Nginx for use with PHP, so I'll consider that out of scope for this.

Assuming you already have the PHP FastCGI stuff setup, here's the Nginx rewrite in the config file :
Code:
location / {
    try_files $uri $uri/ /index.php?$args;
}
...no, seriously, that's it ;). What's happening here is identical to the Apache rewrite in that it first looks for files and directories that already exist and then and *only* then, it rewrites the path to index.php with any arguments appended to the end.

Now it's time to get to your index.php. Since this is a testing environment, I like to set *display_errors* on to catch any errors while developing. In production, you should turn this setting off as that can reveal things about your code to people that shouldn't know about them when things break :
Code:
<?php

ini_set( 'display_errors', '1' ); // Remove this in production

/**
* Application base path. This uses index.php's ( this file's) location.
*/
define( 'PATH',        realpath( dirname( __FILE__ ) ) . DIRECTORY_SEPARATOR );


/**
* Application base path. Assuming you have a folder called /libs in your web root.
*/
define( 'PKGS',        PATH . 'libs/' );

We'll need a way to dynamically load our classes when the router kicks in. The old-school way was to require these files ahead of time, which worked OK, but then we're not sure what path the visitor requested. This would make requiring all the other files we don't need pretty pointless. Enter autoload :
Code:
function autoload( $className ) {
    $className = ltrim( $className, '\\' );
    $fileName  = '';
    $namespace = '';
    
    if ( $lastNsPos = strrpos( $className, '\\' ) ) {
        $namespace = substr( $className, 0, $lastNsPos);
        $className = substr( $className, $lastNsPos + 1 );
        $fileName  = str_replace( '\\', DIRECTORY_SEPARATOR, $namespace ) .
                DIRECTORY_SEPARATOR;
    }
    $fileName .= str_replace( '_', DIRECTORY_SEPARATOR, $className ) . '.php';

    require PKGS . strtolower( $fileName );
}
Note: The last line converts all file names to lowercase. This is for consistency in my case, but you can leave this out if you're sure to name the file with the exact case of the class [ in Windows, this doesn't really matter, but in sane operating systems, it does ;) ].

And now, of course, we need a list of the URLs we intend to map.

The way this works is by having a specific pattern of path be served by a specific controller. The 'C' in MVC (Model View Controller) gets over used these days, but I prefer not to adhere to competely strict structures unless they serve a purpose. If it makes sense, go with it, but above all else, learn to go with the flow. ;)
Code:
/**
* URL map
*/
$map = array(
    '/([1-9][0-9]?+)?'    => 'posts',    // Index (Mapped to the 'posts' controller)
    '/posts/([1-9][0-9]?+)/?'=> 'posts'    // Post (The same, but for a specific post)
);
This is a very simple implementation so we're only using basically one controller, but you can have as many as is necessary to get your site going. Note the regular expression here : ([1-9][0-9]+) What this tells is to make sure that any number in the URL starts with 1-9. Because it would be pretty silly to have a page like '/001'. This way, when your site first loads and the index is requested, the 'posts' controller will get loaded and handed the request.

Also note, the question mark afterwards. This means, 'whether or not it's specified'. I.E. Whether visiting '/1' or '/' it should be mapped to the 'posts' controller. Likewise '/posts/123' and '/posts/123/' (ending in backslash) means the same thing. It's good to take into account little tidbits like this since you can't always expect people to copy/type URLs exactly as expected.

I think this is a pretty standard layout for a lot of sites. You visit the index page and see a list of posts/entries or what have you. And browsing the rest is easy by visiting page /2 or /3 or /4 etc... Clicking on the link on the list of posts will bring you to a specific one, E.G. /posts/123. You can certainly expand this to work with dates or other complicated things, but for our example, this works OK.

Now to complete the index.php file, we have one last thing to add :
Code:
function init( $map ) {
    try {
        spl_autoload_register( 'autoload' );
    
        $router = new Router();
        $router->route( $map );
    
    } catch( Exception $e ) {
        die( $e->getMessage() );
    }
}


// Begin
init($map);
I don't like putting raw code here and there unless it's a setting of some sort [ it's an OCD thing ;) ] so this nicely bundled function tells the PHP core to use our 'autoload' function to... autoload our classes. And it creates a new Router. What the heck is 'Router'?

The fun part starts now...

In your /libs folder, create a new file called *router.php* . Remember to make this lowercase since we used strtolower() in our 'autoload' function. Now I'm not a fan of just giving code instead of explaining it line-by-line, but since the class is pretty small and I've entered comments as best I can, you can just copy > paste this. I'm confident you'll figure out what each part of the code does ;) :
Code:
<?php
/**
* URL Router class gets the current request and method
* and calls the appropriate controller based on URL map
*/
class Router {
    
    /**
     * @var string Request method
     */
    private $_method;
    
    
    /**
     * @var string Request path
     */
    private $_path;
    
    
    /**
     * @var array Allowed methods
     *     Controllers *must* have these (and 'run') even if they do nothing
     */
    private $_safe = array( 'get', 'post', 'put' );
    
    
    /**
     * Router constructor initiates the method and current path.
     * The request method will get filtered through the safe list.
     */
    public function __construct() {
        
        /**
         * get, post, put etc...
         */
        $method = strtolower( $_SERVER['REQUEST_METHOD'] );
        
        /**
         * '/', '/posts', '/posts/123' etc...
         */
        $this->_path = $_SERVER['REQUEST_URI'];
        
        /**
         * Safe request methods only.
         * All others get sent to the catch-all 'run' method.
         */
        if ( in_array( $method, $this->_safe ) ) {
            $this->_method = $method;
        } else {
            $this->_method = 'run';
        }
    }
    
    /**
     * Using provided map in '/path' => 'controller' format,
     * sends class name to the class and method loader.
     *
     * @link https://github.com/jtopjian/gluephp/blob/master/glue.php
     * @param array $map Url to controller map
     */
    public function route( $map ) {
        $found = false;
        
        /**
         * Sort to ensure first match
         */
        ksort( $map );
        
        
        /**
         * For each matching URL send the provided class name to the loader
         */
        foreach ( $map as $pattern => $class ) {
        
            /**
             * Regex formatting clean up
             */
            $regex = str_replace( '/', '\/', $pattern );
            $regex = '^' . $regex . '\/?$';
            
            if ( preg_match( "/$regex/i", $this->_path, $matches ) ) {
                $found = true;
                
                /**
                 * Match found. Load the controller and exit the loop.
                 */
                $this->load( $class, $matches );
                break;
            }
        }
        
        
        /**
         * Chocolate? This is doo doo baby!
         */
        if ( !$found ) {
            die( 'Couldn\'t find that.' );
        }
    }
    
    
    /**
     * Class loader uses the class name and loads from the Controllers namespace
     *
     * @param string $class Class name
     * @param array $matches URL matches
     */
    public function load( $class, $matches ) {
        
        try {
            $class        = '\\Controllers\\'  .  $class;
            $controller    = new $class();  // Autoload takes care of this
            
            $method = $this->_method;
            if ( is_callable( array( $controller, $method ) ) ) {
                
                call_user_func_array(
                    array( $controller, $method ),
                    array( $matches )
                );
                return;
            }
            
            /**
             * Since the code above invokes 'return' which exits the function,
             * if the controller and method can be called, if we made it this far,
             * then the request method was never implemented in the class.
             */
            die( 'Method not implemented' );
            
        } catch ( Exception $e ) {
        
            /**
             * Michael Corleone says 'hello'
             */
            die( 'Can\'t load controller' );
        }
    }
}
Remember our /controllers folder inside the /libs folder we created earlier? This is where we stick all our... controllers. Note how the last 'load' function sets the class as '\\\Controllers\\\name'. This path is relative to the 'PKGS' defined variable in our index.php file which the 'autoload' function will use to load our controller. No manual requires necessary ;)


Now in our /libs/controllers folder, we can finally create our posts controller. We'll name it posts.php :
Code:
<?php
/**
* All controllers go in the Controllers namespace
*/
namespace Controllers;

class posts {
    
    /**
     * In our URL map, the index ('/') as well as specific posts (E.G. '/posts/123')
     * are both handled by the posts controller
     */
    public function get( $pattern ) {
        echo "Get was called.";
        
        /**
         * See what came through here. It should be the path arguments
         * in array format that you can play around with
         */
        var_dump( $pattern );
    }
    
    
    /**
     * If you're creating new posts, POST is where you'd handle that
     */
    public function post( $pattern ) {
        echo "Post was called. I didn't actually pay attention to what you posted :(";
    }
    
    
    /**
     * PUT is often a simpler way to upload files I.E. images/video to your site
     * instead of POST, but that's another tutorial ;)
     */
    public function put( $pattern ) {
        echo "Nothing to put here";
    }
    
    
    /**
     * Our catch-all method for unsafe or unknown methods
     */
    public function run( $pattern ) {
        echo "I don't know what you're trying to feed me!";
    }
}

And that's about it! Navigate to your site and play around with the URLs.

Enjoy! :)

Edit: Made a few corrections and modified the regex a bit.
Jayro
Long time nixers
This is a very useful and high quality guide! Thank you.
venam
Administrators
Indeed this is useful. I'll need to test that sooner or later.
eksith
Long time nixers
Thank you, guys! I made a tinsey correction so it should run with no issues. :)