As you might recall from previous chapters, the main purpose of a web application is to process HTTP requests coming from the client and return a response. If that is the main goal of your application, managing requests and responses should be an important part of your code.
PHP is a language that can be used for scripts, but its main usage is in web applications. Due to this, the language comes ready with a lot of helpers for managing requests and responses. Still, the native way is not ideal, and as good OOP developers, we should come up with a set of classes that help with that. The main elements for this small project—still inside your application—are the request and the router. Let's start!
As we start our mini framework, we need to change our directory structure a bit. We will create the src/Core
directory for all the classes related to the framework. As the configuration reader from the previous chapters is also part of the framework (rather than functionality for the user), we should move the Config.php
file to this directory too.
The first thing to consider is what a request looks like. If you remember Chapter 2, Web Applications with PHP, a request is basically a message that goes to a URL, and has a method—GET or POST for now. The URL is at the same time composed of two parts: the domain of the web application, that is, the name of your server, and the path of the request inside the server. For example, if you try to access http://bookstore.com/my-books
, the first part, http://bookstore.com
, would be the domain and /my-books
would be the path. In fact, http
would not be part of the domain, but we do not need that level of granularity for our application. You can get this information from the global array $_SERVER
that PHP populates for each request.
Our Request
class should have a property for each of those three elements, followed by a set of getters and some other helpers that will be useful for the user. Also, we should initialize all the properties from $_SERVER
in the constructor. Let's see what it would look like:
<?php namespace BookstoreCore; class Request { const GET = 'GET'; const POST = 'POST'; private $domain; private $path; private $method; public function __construct() { $this->domain = $_SERVER['HTTP_HOST']; $this->path = $_SERVER['REQUEST_URI']; $this->method = $_SERVER['REQUEST_METHOD']; } public function getUrl(): string { return $this->domain . $this->path; } public function getDomain(): string { return $this->domain; } public function getPath(): string { return $this->path; } public function getMethod(): string { return $this->method; } public function isPost(): bool { return $this->method === self::POST; } public function isGet(): bool { return $this->method === self::GET; } }
We can see in the preceding code that other than the getters for each property, we added the methods getUrl
, isPost
, and isGet
. The user could find the same information using the already existing getters, but as they will be needed a lot, it is always good to make it easier for the user. Also note that the properties are coming from the values of the $_SERVER
array: HTTP_HOST
, REQUEST_URI
, and REQUEST_METHOD
.
Another important part of a request is the information that comes from the user, that is, the GET and POST parameters, and the cookies. As with the $_SERVER
global array, this information comes from $_POST
, $_GET
, and $_COOKIE
, but it is always good to avoid using them directly, without filtering, as the user could send malicious code.
We will now implement a class that will represent a map—key-value pairs—that can be filtered. We will call it FilteredMap
, and will include it in our namespace, BookstoreCore
. We will use it to contain the parameters GET and POST and the cookies as two new properties in our Request
class. The map will contain only one property, the array of data, and will have some methods to fetch information from it. To construct the object, we need to send the array of data as an argument to the constructor:
<?php namespace BookstoreCore; class FilteredMap { private $map; public function __construct(array $baseMap) { $this->map = $baseMap; } public function has(string $name): bool { return isset($this->map[$name]); } public function get(string $name) { return $this->map[$name] ?? null; } }
This class does not do much so far. We could have the same functionality with a normal array. The utility of this class comes when we add filters while fetching data. We will implement three filters, but you can add as many as you need:
public function getInt(string $name) { return (int) $this->get($name); } public function getNumber(string $name) { return (float) $this->get($name); } public function getString(string $name, bool $filter = true) { $value = (string) $this->get($name); return $filter ? addslashes($value) : $value; }
These three methods in the preceding code allow the user to get parameters of a specific type. Let's say that the developer needs to get the ID of the book from the request. The best option is to use the getInt
method to make sure that the returned value is a valid integer, and not some malicious code that can mess up our database. Also note the function getString
, where we use the addSlashed
method. This method adds slashes to some of the suspicious characters, such as slashes or quotes, trying to prevent malicious code with it.
Now we are ready to get the GET and POST parameters as well as the cookies from our Request
class using our FilteredMap
. The new code would look like the following:
<?php namespace BookstoreCore; class Request { // ... private $params; private $cookies; public function __construct() { $this->domain = $_SERVER['HTTP_HOST']; $this->path = explode('?', $_SERVER['REQUEST_URI'])[0]; $this->method = $_SERVER['REQUEST_METHOD']; $this->params = new FilteredMap( array_merge($_POST, $_GET) ); $this->cookies = new FilteredMap($_COOKIE); } // ... public function getParams(): FilteredMap { return $this->params; } public function getCookies(): FilteredMap { return $this->cookies; } }
With this new addition, a developer could get the POST parameter price
with the following line of code:
$price = $request->getParams()->getNumber('price');
This is way safer than the usual call to the global array:
$price = $_POST['price'];
If you can recall from any URL that you use daily, you will probably not see any PHP file as part of the path, like we have with http://localhost:8000/init.php
. Websites try to format their URLs to make them easier to remember instead of depending on the file that should handle that request. Also, as we've already mentioned, all our requests go through the same file, index.php
, regardless of their path. Because of this, we need to keep a map of the URL paths, and who should handle them.
Sometimes, we have URLs that contain parameters as part of their path, which is different from when they contain the GET or POST parameters. For example, to get the page that shows a specific book, we might include the ID of the book as part of the URL, such as /book/12
or /book/3
. The ID will change for each different book, but the same controller should handle all of these requests. To achieve this, we say that the URL contains an argument, and we could represent it by /book/:id
, where id
is the argument that identifies the ID of the book. Optionally, we could specify the kind of value this argument can take, for example, number, string, and so on.
Controllers, the ones in charge of processing requests, are defined by a method's class. This method takes as arguments all the arguments that the URL's path defines, such as the ID of the book. We group controllers by their functionality, that is, a BookController
class will contain the methods related to requests about books.
Having defined all the elements of a route—a URL-controller relationship—we are ready to create our routes.json
file, a configuration file that will keep this map. Each entry of this file should contain a route, the key being the URL, and the value, a map of information about the controller. Let's see an example:
{ "books/:page": { "controller": "Book", "method": "getAllWithPage", "params": { "page": "number" } } }
The route in the preceding example refers to all the URLs that follow the pattern /books/:page
, with page
being any number. Thus, this route will match URLs such as /books/23
or /books/2
, but it should not match /books/one
or /books
. The controller that will handle this request should be the getAllWithPage
method from BookController
; we will append Controller
to all the class names. Given the parameters that we defined, the definition of the method should be something like the following:
public function getAllWithPage(int $page): string { //... }
There is one last thing we should consider when defining a route. For some endpoints, we should enforce the user to be authenticated, such as when the user is trying to access their own sales. We could define this rule in several ways, but we chose to do it as part of the route, adding the entry "login": true
as part of the controller's information. With that in mind, let's add the rest of the routes that define all the views that we expect to have:
{ //... "books": { "controller": "Book", "method": "getAll" }, "book/:id": { "controller": "Book", "method": "get", "params": { "id": "number" } }, "books/search": { "controller": "Book", "method": "search" }, "login": { "controller": "Customer", "method": "login" }, "sales": { "controller": "Sales", "method": "getByUser" , "login": true }, "sales/:id": { "controller": "Sales", "method": "get", "login": true, "params": { "id": "number" } }, "my-books": { "controller": "Book", "method": "getByUser", "login": true } }
These routes define all the pages we need; we can get all the books in a paginated way or specific books by their ID, we can search books, list the sales of the user, show a specific sale by its ID, and list all the books that a certain user has borrowed. However, we are still lacking some of the endpoints that our application should be able to handle. For all those actions that are trying to modify data rather than requesting it, that is, borrowing a book or buying it, we need to add endpoints too. Add the following to your routes.json
file:
{ // ... "book/:id/buy": { "controller": "Sales", "method": "add", "login": true "params": { "id": "number" } }, "book/:id/borrow": { "controller": "Book", "method": "borrow", "login": true "params": { "id": "number" } }, "book/:id/return": { "controller": "Book", "method": "returnBook", "login": true "params": { "id": "number" } } }
The router will be by far the most complicated piece of code in our application. The main goal is to receive a Request
object, decide which controller should handle it, invoke it with the necessary parameters, and return the response from that controller. The main goal of this section is to understand the importance of the router rather than its detailed implementation, but we will try to describe each of its parts. Copy the following content as your src/Core/Router.php
file:
<?php namespace BookstoreCore; use BookstoreControllersErrorController; use BookstoreControllersCustomerController; class Router { private $routeMap; private static $regexPatters = [ 'number' => 'd+', 'string' => 'w' ]; public function __construct() { $json = file_get_contents( __DIR__ . '/../../config/routes.json' ); $this->routeMap = json_decode($json, true); } public function route(Request $request): string { $path = $request->getPath(); foreach ($this->routeMap as $route => $info) { $regexRoute = $this->getRegexRoute($route, $info); if (preg_match("@^/$regexRoute$@", $path)) { return $this->executeController( $route, $path, $info, $request ); } } $errorController = new ErrorController($request); return $errorController->notFound(); } }
The constructor of this class reads from the routes.json
file, and stores the content as an array. Its main method, route
, takes a Request
object and returns a string, which is what we will send as output to the client. This method iterates all the routes from the array, trying to match each with the path of the given request. Once it finds one, it tries to execute the controller related to that route. If none of the routes are a good match to the request, the router will execute the notFound
method of the ErrorController
, which will then return an error page.
While matching a URL with the route, we need to take care of the arguments for dynamic URLs, as they do not let us perform a simple string comparison. PHP—and other languages—has a very strong tool for performing string comparisons with dynamic content: regular expressions. Being an expert in regular expressions takes time, and it is outside the scope of this book, but we will give you a brief introduction to them.
A regular expression is a string that contains some wildcard characters that will match the dynamic content. Some of the most important ones are as follows:
^
: This is used to specify that the matching part should be the start of the whole string$
: This is used to specify that the matching part should be the end of the whole stringd
: This is used to match a digitw
: This is used to match a word+
: This is used for following a character or expression, to let that character or expression to appear at least once or many times*
: This is used for following a character or expression, to let that character or expression to appear zero or many times.
: This is used to match any single characterLet's see some examples:
.*
will match anything, even an empty string.+
will match anything that contains at least one character^d+$
will match any number that has at least one digitIn PHP, we have different functions to work with regular expressions. The easiest of them, and the one that we will use, is pregmatch
. This function takes a pattern as its first argument (delimited by two characters, usually @
or /
), the string that we are trying to match as the second argument, and optionally, an array where PHP stores the occurrences found. The function returns a Boolean value, being true
if there was a match, false
otherwise. We use it as follows in our Route
class:
preg_match("@^/$regexRoute$@", $path)
The $path
variable contains the path of the request, for example, /books/2
. We match using a pattern that is delimited by @
, has the ^
and $
wildcards to force the pattern to match the whole string, and contains the concatenation of /
and the variable $regexRoute
. The content of this variable is given by the following method; add this as well to your Router
class:
private function getRegexRoute( string $route, array $info ): string { if (isset($info['params'])) { foreach ($info['params'] as $name => $type) { $route = str_replace( ':' . $name, self::$regexPatters[$type], $route ); } } return $route; }
The preceding method iterates the parameters list coming from the information of the route. For each parameter, the function replaces the name of the parameter inside the route by the wildcard character corresponding to the type of parameter—check the static array, $regexPatterns
. To illustrate the usage of this function, let's see some examples:
/books
will be returned without a change, as it does not contain any argumentbooks/:id/borrow
will be changed to books/d+/borrow
, as the URL argument, id
, is a numberIn order to execute the controller, we need three pieces of data: the name of the class to instantiate, the name of the method to execute, and the arguments that the method needs to receive. We already have the first two as part of the route $info
array, so let's focus our efforts on finding the third one. Add the following method to the Router
class:
private function extractParams( string $route, string $path ): array { $params = []; $pathParts = explode('/', $path); $routeParts = explode('/', $route); foreach ($routeParts as $key => $routePart) { if (strpos($routePart, ':') === 0) { $name = substr($routePart, 1); $params[$name] = $pathParts[$key+1]; } } return $params; }
This last method expects that both the path of the request and the URL of the route follow the same pattern. With the explode
method, we get two arrays that should match each of their entries. We iterate them, and for each entry in the route array that looks like a parameter, we fetch its value in the URL. For example, if we had the route /books/:id/borrow
and the path /books/12/borrow
, the result of this method would be the array ['id' => 12].
We end this section by implementing the method that executes the controller in charge of a given route. We already have the name of the class, the method, and the arguments that the method needs, so we could make use of the call_user_func_array
native function that, given an object, a method name, and the arguments for the method, invokes the method of the object passing the arguments. We have to make use of it as the number of arguments is not fixed, and we cannot perform a normal invocation.
But we are still missing a behavior introduced when creating our routes.json
file. There are some routes that force the user to be logged in, which, in our case, means that the user has a cookie with the user ID. Given a route that enforces authorization, we will check whether our request contains the cookie, in which case we will set it to the controller class through setCustomerId
. If the user does not have a cookie, instead of executing the controller for the current route, we will execute the showLogin
method of the CustomerController
class, which will render the template for the login form. Let's see how everything would look on adding the last method of our Router
class:
private function executeController( string $route, string $path, array $info, Request $request ): string { $controllerName = 'BookstoreControllers' . $info['controller'] . 'Controller'; $controller = new $controllerName($request); if (isset($info['login']) && $info['login']) { if ($request->getCookies()->has('user')) { $customerId = $request->getCookies()->get('user'); $controller->setCustomerId($customerId); } else { $errorController = new CustomerController($request); return $errorController->login(); } } $params = $this->extractParams($route, $path); return call_user_func_array( [$controller, $info['method']], $params ); }
We have already warned you about the lack of security in our application, as this is just a project with didactic purposes. So, avoid copying the authorization system implemented here.