Creating a Router for an MVC framework
Tags: php, reflection, mvc
Published: 2019-05-26 07:53:16

This is a cross post from my own blog: [Creating a Router for an MVC framework](https://reflection.to/posts/php/1-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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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:
```php
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:
```php
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`.