Vanilla Blog — Part 3 | Advanced Router
Create a full-featured router with plain PHP.
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 asPOST
.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
andstring
. 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!