Vanilla Blog — Part 3 | Advanced Router

Vanilla Blog — Part 3 | Advanced Router

Create a full-featured router with plain PHP.

·

8 min read

Other Parts:


Having covered the basics of routing, views, and controllers, we can now focus on refining our router to address its current limitations. There are two primary issues with the existing router:

  • It cannot handle methods other than GET, such as POST.

  • It does not support routing URIs with parameters, like /posts/{post-slug}.

We will now work on enhancing our router to overcome these challenges and make it more robust and versatile.

Handle Request Methods:

We will call a specified method name instead of add. So, let's add them to the Router class.

// app/Core/Router.php
<?php

class Router
{

    public function get($uri, $controller, $closure = 'index')
    {
        return $this->add($uri, 'GET', $controller, $closure);
    }

    public function post($uri, $controller, $closure = 'index')
    {
        return $this->add($uri, 'POST', $controller, $closure);

    }

    public function patch($uri, $controller, $closure = 'index')
    {
        return $this->add($uri, 'PATCH', $controller, $closure);

    }

    public function put($uri, $controller, $closure = 'index')
    {
        return $this->add($uri, 'PUT', $controller, $closure);
    }

    public function delete($uri, $controller, $closure = 'index')
    {
        return $this->add($uri, 'DELETE', $controller, $closure);
    }

    private function add(string $uri, string $method, string $controller, string $closure = "index")
    {
        $this->routes[] = compact("uri", "controller", "closure", "method");

        return $this;
    }

    public function route()
    {
        foreach ($this->routes as $route) {
            // Add methode to mathRoute
            if ($this->matchRoute($route['uri'], $route['method'])) {
                $this->render($route['controller'], $route['closure']);
                exit();
            }
        }

        abort(404);
    }
    // match also methode
    private function matchRoute(string $routeUri, string $method): bool
    {
        $server_uri = preg_replace("/(^\/)|(\/$)/", "", parse_url($_SERVER['REQUEST_URI'])['path']);
        $server_method = strtoupper($_SERVER["REQUEST_METHOD"]);

        parse_str($_SERVER['QUERY_STRING'], $queries);
        $this->data['queries'] = $queries;

        if (!empty($server_uri)) {
            $routeUri = preg_replace("/(^\/)|(\/$)/", "", $routeUri);
            $reqUri = preg_replace("/(^\/)|(\/$)/", "", $server_uri);
        } else {
            $reqUri = "/";
        }

        return $reqUri == $routeUri && $server_method == $method;
    }
}

Let's update web.php :

// routes/web.php
<?php
...
$router->get("/", "HomeController");
$router->get("/about", "AboutController");
$router->get("/contact", "ContactController");

// Add new POST route to test if it's working
$router->post("/contact", "ContactController", "send");
...

Add send method to ContactController :

// app/controllers/ContactController.php

<?php

class ContactController
{
    ...
    public function send()
    {
        dd($_POST);
    }
}

I added a contact form from TailwindUI.

// views/contact.view.php
<?php component("head") ?>

<div class="bg-white px-6 py-24 sm:py-32 lg:px-8">
    <div class="mx-auto max-w-2xl text-center">
        <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">Contact us</h2>
        <p class="mt-2 text-lg leading-8 text-gray-600">Aute magna irure deserunt veniam aliqua magna enim voluptate.</p>
    </div>
    <form method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
        <div class="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
            <div>
                <label for="first-name" class="block text-sm font-semibold leading-6 text-gray-900">First name</label>
                <div class="mt-2.5">
                    <input type="text" name="first-name" id="first-name" autocomplete="given-name" class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
                </div>
            </div>
            <div>
                <label for="last-name" class="block text-sm font-semibold leading-6 text-gray-900">Last name</label>
                <div class="mt-2.5">
                    <input type="text" name="last-name" id="last-name" autocomplete="family-name" class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
                </div>
            </div>
            <div class="sm:col-span-2">
                <label for="email" class="block text-sm font-semibold leading-6 text-gray-900">Email</label>
                <div class="mt-2.5">
                    <input type="email" name="email" id="email" autocomplete="email" class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
                </div>
            </div>
            <div class="sm:col-span-2">
                <label for="message" class="block text-sm font-semibold leading-6 text-gray-900">Message</label>
                <div class="mt-2.5">
                    <textarea name="message" id="message" rows="4" class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"></textarea>
                </div>
            </div>
        </div>
        <div class="mt-10">
            <button type="submit" class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Send</button>
        </div>
    </form>
</div>

<?php component("footer") ?>

The important part is input names and form method:

<form method="POST">

Action is by default the current URI, which is /contact :

After submitting the form, we will get:

We have successfully handled the POST method also.

HTML Form Method problem:

HTML forms natively support only GET and POST methods. However, there are workarounds to handle DELETE, PUT, and PATCH methods within HTML forms. One common approach is to include a hidden input field in the form and set its value to the desired method name. This way, when the form is submitted, you can access the method value on the server side and handle the request accordingly.

<input type="hidden" value="delete" name="_method"/>

Let's add a small change to the Router class:

<?php

class Router
{
    private function matchRoute(string $routeUri, string $method): bool
    {
        ...
        // If there is _method input, take its value otherwise, take the server method
        $server_method = strtoupper($_POST['_method'] ?? $_SERVER["REQUEST_METHOD"]);
        ...
    }
}

Handle routes with parameters

We will have to update the matchRoute method. Let's do that, and I will explain it after.

// app/Core/Router.php
<?php

class Router
{
    ...
    private function matchRoute(string $uri, string $method): bool
    {
        // Get Server URI and Method
        $server_uri = preg_replace("/(^\/)|(\/$)/", "", parse_url($_SERVER['REQUEST_URI'])['path']);
        $server_method = strtoupper($_POST['_method'] ?? $_SERVER["REQUEST_METHOD"]);

        // If the method not matching, abort
        if ($method != $server_method) {
            return false;
        }

        // Get queries
        parse_str($_SERVER['QUERY_STRING'], $queries);
        $this->data['queries'] = $queries;

        $params = [];
        $paramKey = [];

        // Get the params. e.g. {paramKey} and store them in $paramMatches
        preg_match_all("/(?<={).+?(?=})/", $uri, $paramMatches);

        $routeUri = $uri;

        // Clean up URIs: /\uri => /uri
        if (!empty($server_uri)) {
            $routeUri = preg_replace("/(^\/)|(\/$)/", "", $uri);
            $reqUri =  preg_replace("/(^\/)|(\/$)/", "", $server_uri);
        } else {
            $reqUri = "/";
        }

        // If there is no param, match as simple route
        if (empty($paramMatches[0])) {
            return $reqUri == $routeUri && $method == $server_method;
        }

        // Push paramKeys to $paramKey array
        foreach ($paramMatches[0] as $key) {
            $paramKey[] = $key;
        }

        // explode route URI '/post/{slug} => ['post', '{slug}']
        $uri = explode("/", $routeUri);

        $indexNum = [];

        foreach ($uri as $index => $param) {
            if (preg_match("/{.*}/", $param)) {
                $indexNum[] = $index;
            }
        }

        // explode request URI '/post/some-post-slug => ['post', 'some-post-slug']
        $reqUri = explode("/", $reqUri);

        foreach ($indexNum as $key => $index) {

            if (empty($reqUri[$index])) {
                return false;
            }

            // Set value of the param stored previously in $params
            $params[$paramKey[$key]] = $reqUri[$index];

            // Replace the param with {.*} to be able to regex match
            $reqUri[$index] = "{.*}";
        }

        $reqUri = implode("/", $reqUri);

        $reqUri = str_replace("/", '\\/', $reqUri);

        // match /post/{.*} with /post/some-slug
        if (preg_match("/$reqUri/", $routeUri)) {
            $this->data = compact("queries", "params");
            return true;
        }

        // If there is no match, return false
        return false;
    }
    ...
}

These parts haven't changed:


// Get Server URI and Method
$server_uri = preg_replace("/(^\/)|(\/$)/", "", parse_url($_SERVER['REQUEST_URI'])['path']);
$server_method = strtoupper($_POST['_method'] ?? $_SERVER["REQUEST_METHOD"]);
parse_str($_SERVER['QUERY_STRING'], $queries);

$routeUri = $uri;

// Clean up URIs: /\uri => /uri
if (!empty($server_uri)) {
    $routeUri = preg_replace("/(^\/)|(\/$)/", "", $uri);
    $reqUri =  preg_replace("/(^\/)|(\/$)/", "", $server_uri);
} else {
    $reqUri = "/";
}

return $reqUri == $routeUri && $method == $server_method;

In the updated router, it attempts to match /{param} if it exists as a defined route. If the /{param} route is not found, it proceeds to match the server URI with the route URI defined by the user in the web.php file. This approach allows for more flexible routing, accommodating both fixed routes and routes with parameters dynamically.

This line, find matches with Regex and stores them in an array:

// Get the params. e.g. {paramKey} and store them in $paramMatches
preg_match_all("/(?<={).+?(?=})/", $uri, $paramMatches);

It returns this:

[ [ "slug" ] ]

So, if $paramMatches[0] is empty, there is no param, and it continues then as a simple URI.

Else, we have another array named: $paramKey, to store keys.

// Push paramKeys to $paramKey array
foreach ($paramMatches[0] as $key) {
    $paramKey[] = $key;
}

Next, we explode the route URI /post/{slug} to get the param index later:

// explode route URI '/post/{slug} => ['post', '{slug}']
$uri = explode("/", $routeUri);

To get the index, we loop through $uri. If a match is found, it will be added to $indexNum.

$indexNum = [];

foreach ($uri as $index => $param) {
    if (preg_match("/{.*}/", $param)) {
        $indexNum[] = $index;
    }
}

Note: This function can match multiple params, that's why we have the arrays instead of int and string. e.g. /team/{team_id}/{user_id} .

Now that we have obtained the index of parameters, our next step is to match them with the server URI. To accomplish this, we can utilize regular expressions (regex) to establish the necessary pattern matching. Regex enables us to define and compare patterns to identify and extract the desired parameters from the server URI.

Example 1:

// Route URI
/post/some-post-slug

// Server URI
/post/{.*}

Example 2:

// Route URI
/team/{team_id}/{user_id}

// Server URI
/team/{.*}/{.*}

And, to do that:

// explode request URI '/post/some-post-slug => ['post', 'some-post-slug']
$reqUri = explode("/", $reqUri);

foreach ($indexNum as $key => $index) {

    if (empty($reqUri[$index])) {
        return false;
    }

    // Set value of the param stored previously in $params
    $params[$paramKey[$key]] = $reqUri[$index];

    // Replace the param with {.*} to be able to regex match
    $reqUri[$index] = "{.*}";
}

For the first example, the $params will be:

[
    'slug' => 'some-post-slug'
]

And Server URI will be now /post/{.*} .

Finally, let's match the URIs:

// match /post/{.*} with /post/some-post-slug
if (preg_match("/$reqUri/", $routeUri)) {
    $this->data = compact("queries", "params");
    return true;
}

Final Test:

I will add a new route to web.php routes:

// routes/web.php

$router->get("/post/{slug}", "HomeController", "post");

And, add the post method to HomeController :

// app/controllers/HomeController.php

<?php

class HomeController
{
    ...
    public function post(){
        dd($this->data);
    }
}

If we visit this route, we will get:

{
    "queries": [],
    "params": {
        "slug": "some-post-slug"
    }
}

Conclusion:

Great news! With the implementation of multiple request methods handling and URI parameter routing, our router has reached a fully functional state at this level. Now, we can proceed to incorporate a database and make our application dynamic.

See you at the next one!