DEV Community

Emanuel Vintila
Emanuel Vintila

Posted on • Originally published at reflection.to

Creating a Router for an MVC framework

This is a cross post from my own blog: Creating a Router for an MVC framework

If you are not familiar with MVC I suggest you read an article on that topic before reading this one.

Most of the code in this post requires at least PHP version 7.1.

Let us assume your domain is example.com and you want to build your application such that a request to example.com/gallery/cats displays a gallery of kitty pictures to the user and a request to example.com/gallery/dogs should obviously display a gallery of dogs.

By analyzing the URL we notice that the gallery word does not change, only the cats, dogs, or whatever else you would have pictures of in your database. Then we will create a class that handles this logic.

<?php
// GalleryController.php
class GalleryController
{
    /** @var string */
    private $animal_type;

    public function __construct(string $animal_type)
    {
        $this->animal_type = $animal_type;
    }

    public function display()
    {
        // do whatever you need here, fetch from database, etc.
        echo $this->animal_type;
    }
}

So, when a user points their browser to example.com/gallery/cats, the application needs to instantiate the newly defined class GalleryController with the cats argument, and then call the display method. We will implement the router using regular expressions. Let us first define a data class that will associate a request with a controller and a method.

<?php
// Route.php
class Route
{
    /** @var string */
    public $path;
    /** @var string */
    public $controller;
    /** @var string */
    public $method;

    public function __construct(string $path, string $controller, string $method)
    {
        $this->path = $path;
        $this->controller = $controller;
        $this->method = $method;
    }
}

We are going to define a very simple Router class and then we will use it to see how it works.

<?php
// Router.php
class Router
{
    /** @var Route[] */
    private $routes;

    public function register(Route $route)
    {
        $this->routes[] = $route;
    }

    public function handleRequest(string $request)
    {
        $matches = [];
        foreach ($this->routes as $route) {
            if (preg_match($route->path, $request, $matches)) {
                // $matches[0] will always be equal to $request, so we just shift it off
                array_shift($matches);
                // here comes the magic
                $class = new ReflectionClass($route->controller);
                $method = $class->getMethod($route->method);
                // we instantiate a new class using the elements of the $matches array
                $instance = $class->newInstance(...$matches);
                // equivalent:
                // $instance = $class->newInstanceArgs($matches);
                // then we call the method on the newly instantiated object
                $method->invoke($instance);
                // finally, we return from the function, because we do not want the request to be handled more than once
                return;
            }
        }
        throw new RuntimeException("The request '$request' did not match any route.");
    }
}

Now, to actually run the application and test the Router class, create an index.php file with the following contents, and configure your web server to redirect all the requests to it.

<?php
// index.php
spl_autoload_extensions('.php');
spl_autoload_register();

$router = new Router();
$router->register(new Route('/^\/gallery\/(\w+)$/', 'GalleryController', 'display'));
$router->handleRequest($_SERVER['REQUEST_URI']);

Finally, point your browser to example.com/gallery/cats and it will display the word cats on your screen. The way it works is fairly simple:

  1. We register the route with the router and we tell it to handle the incoming request
  2. The router checks trough all the registered routes to see if the request matches any of them
  3. It finds the matching route and instantiates a new class with the specified name, supplying the required arguments to its constructor, and then invokes the specified method onto the instance

That is pretty easy. Let us go a bit further. What if your controller constructor accepts an object as a parameter instead of a primitive type? We are going to define a User class that exposes their name and age:

<?php
// User.php
class User
{
    /** @var string */
    public $name;
    /** @var int */
    public $age;

    public function __construct(string $name, int $age)
    {
        $this->name = $name;
        $this->age = $age;
    }
}

And a UserController class that accepts an User as an argument:

<?php
// UserController.php
class UserController
{
    /** @var User */
    private $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function show()
    {
        echo "{$this->user->name} is {$this->user->age} years old.";
    }
}

Let us register a new route in the main script, right before the call to handleRequest:
$router->register(new Route('/^\/users\/(\w+)\/(\d+)$/', 'UserController', 'show'));
And now, if you go to example.com/users/mike/26, you will get an Exception, because the Router tries to pass a string to UserController's constructor, instead of an User. The fix involves using more reflection.

<?php
// Router.php
class Router
{
    /** @var Route[] */
    private $routes;

    public function register(Route $route)
    {
        $this->routes[] = $route;
    }

    public function handleRequest(string $request)
    {
        $matches = [];
        foreach ($this->routes as $route) {
            if (preg_match($route->path, $request, $matches)) {
                // $matches[0] will always be equal to $request, so we just shift it off
                array_shift($matches);
                // here comes the magic
                $class = new ReflectionClass($route->controller);
                $method = $class->getMethod($route->method);
                // we construct the controller using the newly defined method
                $instance = $this->constructClassFromArray($class, $matches);
                // then we call the method on the newly instantiated object
                $method->invoke($instance);
                // finally, we return from the function because we do not want the request to be handled more than once
                return;
            }
        }
        throw new RuntimeException("The request '$request' did not match any route.");
    }

    private function constructClassFromArray(ReflectionClass $class, array &$array)
    {
        $parameters = $class->getConstructor()->getParameters();
        // construct the arguments needed for its constructor
        $args = [];
        foreach ($parameters as $parameter)
            $args[] = $this->constructArgumentFromArray($parameter, $array);

        // then return the new instance
        return $class->newInstanceArgs($args);
    }

    private function constructArgumentFromArray(ReflectionParameter $parameter, array &$array)
    {
        $type = $parameter->getType();
        // if the parameter was not declared with any type, just return the next element from the array
        if ($type === null)
            return array_shift($array);

        // if the parameter is a primitive type, just cast it
        switch ($type->getName()) {
            case 'string':
                return (string) array_shift($array);
            case 'int':
                return (int) array_shift($array);
            case 'bool':
                return (bool) array_shift($array);
        }

        $class = $parameter->getClass();
        // if the parameter is a class type
        if ($class !== null) {
            // make another call that will actually call this method
            return $this->constructClassFromArray($class, $array);
        }

        throw new RuntimeException("Cannot construct the '{$parameter->getName()}' parameter in {$parameter->getDeclaringClass()->getName()}::{$parameter->getDeclaringFunction()->getName()} because it is of an invalid type{$type->getName()}.");
    }
}

Now the UserController class gets instantiated correctly, because the Router knows how to construct the User argument that the controller expects. But, what happens if the constructor for User has a nullable boolean parameter that represents a three-state value, for example whether they passed a certain test? Of course, PHP's boolean conversion rules still hold. Let us modify the User class to include this three-state boolean:

<?php
// User.php
class User
{
    /** @var string */
    public $name;
    /** @var int */
    public $age;
    /** @var bool|null */
    public $passed_test;

    public function __construct(string $name, int $age, ?bool $passed_test)
    {
        $this->name = $name;
        $this->age = $age;
        $this->passed_test = $passed_test;
    }
}

And UserController's show method to:

public function show()
{
    $message = 'invalid';
    if ($this->user->passed_test === true)
        $message = 'They passed the test!';
    elseif ($this->user->passed_test === false)
        $message = 'They didn\'t pass the test!';
    elseif ($this->user->passed_test === null)
        $message = 'They didn\'t attempt the test yet.';
    echo "{$this->user->name} is {$this->user->age} years old.\n";
    echo $message;
}

Finally let us modify the users route, to reflect the change:
$router->register(new Route('/^\/users\/(\w+)\/(\d+)\/?(\w+)?$/', 'UserController', 'show'));
Pointing your browser again to example.com/users/mike/26 will actually set their passed_test property to false instead of null. But why? That is because when we are constructing the User class, its constructor expects 3 arguments, but the URL only contains two. Thus, the last call to array_shift in constructArgumentFromArray actually returns null, that gets cast to bool, which is false. This is a straightforward fix. The constructArgumentFromArray method becomes:

private function constructArgumentFromArray(ReflectionParameter $parameter, array &$array)
{
    $type = $parameter->getType();
    // if the parameter was not declared with any type, just return the next element from the array
    if ($type === null)
        return array_shift($array);

    $class = $parameter->getClass();
    // if the parameter is a class type
    if ($class !== null) {
        // make another call that will actually call this method
        return $this->constructClassFromArray($class, $array);
    }

    // we ran out of $array elements
    if (count($array) === 0)
        // but we can pass null if the parameter allows it
        if ($parameter->allowsNull())
            return null;
        else
            throw new RuntimeException("Cannot construct the '{$parameter->getName()}' in {$parameter->getDeclaringClass()->getName()}::{$parameter->getDeclaringFunction()->getName()} because the array ran out of elements.");

    // if the parameter is a primitive type, just cast it
    switch ($type->getName()) {
        case 'string':
            return (string) array_shift($array);
        case 'int':
            return (int) array_shift($array);
        case 'bool':
            return (bool) array_shift($array);
    }

    throw new RuntimeException("Cannot construct the '{$parameter->getName()}' parameter in {$parameter->getDeclaringClass()->getName()}::{$parameter->getDeclaringFunction()->getName()} because it is of an invalid type{$type->getName()}.");
}

And now, the User class is instantiated correctly when we point the browser to example.com/users/mike/26 and to example.com/users/mike/26/any_truthy_or_falsy_value.

Top comments (0)