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:
- We register the route with the router and we tell it to handle the incoming request
- The router checks trough all the registered routes to see if the request matches any of them
- 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)