My Little Corner of the Net

A PHP Router In 45 Lines

Like most things PHP, I have a love-hate relationship with URL routers. Having gotten my start in building “dynamic websites” back in the 90’s when CGI scripts were about the only option, I still prefer simplicity. CGI scripts, generally written in Perl, were slow and had to be started up for every request that came in, so we tried to do as little in them as possible so they world run faster. Because of that, it wasn’t uncommon to have multiple CGI scripts on a site.

Routers came out of modern web app design. Most apps written in languages such as Java, Go, NodeJS, Ruby, or Python run as their own server. There’s basically one program with an endless loop that listens for a request, processes it, and then goes back and listens for the next. The whole app gets loaded into memory once, and every request for every user is handled by that one instance. (That’s an oversimplification, but it’s good enough for this argument.) These apps needed a way to understand what a user was requesting and from this a fairly standard way of mapping URL patterns to functions emerged.

PHP is kind of stuck somewhere between old CGI scripting and modern web apps. While we typically use PHP “servers,” such as FPM, to keep the PHP interpreter running (which speeds up how long it takes before a PHP script can start processing a request), the scripts themselves are still written like old CGI scripts to handle a single request. On the other hand, many PHP URL routers (and in fact, the entire PHP-FIG-defined routing spec) are modeled after how routing is done in the “app is a server” models, which overcomplicates the code and ultimately slows down PHP. Things like request and response objects simply aren’t necessary when you already have PHP’s super globals and you can just write to STDOUT.

Still, as PHP apps become more complex and require more and more dependencies, I like the idea of the front controller model where all requests enter through a single endpoint, as it makes managing dependencies easier. My go-to router for most of my apps these days is Bramus Router. It’s straightforward, easy to use, and I really like that I can use regular expressions to define my routes. Using patterns to validate URL parameters saves me a ton of time not writing simple string validation code.

The other day I started a new project. It’s a super simple app, basically just putting an web interface in front of a set of command line tools. The app will probably have about a dozen routes and doesn’t need to be fancy. The output isn’t much more than some forms and tables, so I decided to just output HTML from my controllers rather than use a template library to handle views, and there’s no need for a database since the app will just be calling the command line tools. I didn’t want to go through the whole process of setting up Composer just to install Bramus Router because I have zero other external dependencies.

So I wrote my own router. It took me about 15 minutes and ended up, without comments, to be exactly 30 lines of code. it consists of one main function, route() which handles parsing the request URI and handling the request on a match, two helper functions: get() and post() for defining GET and POST routes, respectively, and a notFound() function for returning a 404 error when no routes match the request. Like Bramus Router, my routes are defined by regular expressions (though I did not add support for named placeholders the way Bramus Router does). I also did not add handlers for PUT, PATCH, or DELETE since I don’t need them for this app.

<?php
function route($methods, $path, $callable=null, $include=null) {
    $methods = is_array($methods) ? $methods : array_map('trim', explode(',', $methods));
    $matches = [];
    if(in_array($_SERVER["REQUEST_METHOD"], $methods) && preg_match("|$path|", $_SERVER['REQUEST_URI'], $matches)) {
        if($include) {
           include($include);
       }

       if($callable) {
           array_shift($matches);
           call_user_func_array($callable, $matches);
       }

       exit();
   }
}

function get($path, $callable=null, $include=null) {
    route(['GET'], $path, $callable, $include);
}

function post($path, $callable=null, $include=null) {
    route(['POST'], $path, $callable, $include);
}

function notFound($callable=null, $include=null) {
    http_response_code(404);
    route($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $callable, $include);
}

Each route can have a callback function, an include file, or both. For simple, static routes, no callback needs to be defined and the include can be used to display content. If both a callable and an include are defined, the include will be included first, making it possible to load parts of the app (including the callable function itself) on the fly, thereby preventing the need to compile the entire app on every request (though this will get messy fast and should probably be considered an antipattern).

Here’s some sample routes for a contrived “hello world” app:

<?php
require 'router.php';

// prompt the user for their name so that we can say hello to them
get('^/name/?$', function () {
    echo <<< EOF
  <form mehtod="post">
    <label>Your name: <input type="text" name="name"></label>
    <button>Submit</button>
  </form>
EOF;
});

// load the render library and say hello to the user by name
// note: the render() function is defined in render.php
post('^/name/?$', function () {
    render('Hello, ' . $_POST['name']);
}, 'render.php');

// say hello to the name passed as part of the URL
get('^/(\w+)/?$', function ($name) {
    echo('<h1>Hello, ' . $name . '</h1>');
});

// display the output of hello.php as the app's homepage
get('^/$', null, 'hello.php');

// if no route is matched, send a 404 error
notFound(function () {
    echo('<h1>404 Not Found</h1>');
});

As I was working on the app, I decided that having middleware would be helpful for handling CSRF protection , so I added a before() function, bringing the whole system up to 45 lines of code. The before() function works exactly the same as route(), it just doesn’t exit when it completes.

function before($methods, $path, $callback=null, $include=null) {
    $methods = is_array($methods) ? $methods : array_map('trim', explode(',', $methods));
    $matches = [];
    if(in_array($_SERVER["REQUEST_METHOD"], $methods) && preg_match("|$path|", $_SERVER['REQUEST_URI'], $matches)) {
        if($include) {
            include($include);
        }

        if($callback) {
            array_shift($matches);
            call_user_func_array($callback, $matches);
        }
    }
}

For example:

// called every time a POST request is received
before('POST', '.*', function() {
    echo 'This is a POST request. <br>';
});

Since the handlers are functions, they are executed in the order they are defined. Therefore, any before() middleware should be defined first, then get() and post() routes, and finally the notFound() route last. Otherwise, some parts of the app may never run.

This approach does have some limitations. For example, this approach can’t return a 405 error (method not allowed) when a route’s path matches but the corresponding method does not. That said, many PHP routers I’ve looked at, including Bramus Router, don’t handle this properly. Other than that, it seems to work pretty well, and I may start using it in some of my other projects, though it’s probably best for apps with very little complexity.

Interested in using it? Feel free. I haven’t published it anywhere besides here, but feel free to copy the code and use it in your own projects. Like with most of my code, just be sure to stick to the terms of the MIT license.

Leave a Reply

You can use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

<