It is finally time for the director of the orchestra. Controllers represent the layer in our application that, given a request, talks to the models and builds the views. They act like the manager of a team: they decide what resources to use depending on the situation.
As we stated when explaining models, it is sometimes difficult to decide if some piece of logic should go into the controller or the model. At the end of the day, MVC is a pattern, like a recipe that guides you, rather than an exact algorithm that you need to follow step by step. There will be scenarios where the answer is not straightforward, so it will be up to you; in these cases, just try to be consistent. The following are some common scenarios that might be difficult to localize:
Now that we've set the ground, let's prepare our application to use controllers. The first thing to do is to update our index.php
, which has been forcing the application to always render the same template. Instead, we should be giving this task to the router, which will return the response as a string that we can just print with echo
. Update your index.php
file with the following content:
<?php use BookstoreCoreRouter; use BookstoreCoreRequest; require_once __DIR__ . '/vendor/autoload.php'; $router = new Router(); $response = $router->route(new Request()); echo $response;
As you might remember, the router instantiates a controller class, sending the request object to the constructor. But controllers have other dependencies as well, such as the template engine, the database connection, or the configuration reader. Even though this is not the best solution (you will improve it once we cover dependency injection in the next section), we could create an AbstractController
that would be the parent of all controllers, and will set those dependencies. Copy the following as src/Controllers/AbstractController.php
:
<?php namespace BookstoreControllers; use BookstoreCoreConfig; use BookstoreCoreDb; use BookstoreCoreRequest; use MonologLogger; use Twig_Environment; use Twig_Loader_Filesystem; use MonologHandlerStreamHandler; abstract class AbstractController { protected $request; protected $db; protected $config; protected $view; protected $log; public function __construct(Request $request) { $this->request = $request; $this->db = Db::getInstance(); $this->config = Config::getInstance(); $loader = new Twig_Loader_Filesystem( __DIR__ . '/../../views' ); $this->view = new Twig_Environment($loader); $this->log = new Logger('bookstore'); $logFile = $this->config->get('log'); $this->log->pushHandler( new StreamHandler($logFile, Logger::DEBUG) ); } public function setCustomerId(int $customerId) { $this->customerId = $customerId; } }
When instantiating a controller, we will set some properties that will be useful when handling requests. We already know how to instantiate the database connection, the configuration reader, and the template engine. The fourth property, $log
, will allow the developer to write logs to a given file when necessary. We will use the Monolog library for that, but there are many other options. Notice that in order to instantiate the logger, we get the value of log from the configuration, which should be the path to the log file. The convention is to use the /var/log/
directory, so create the /var/log/bookstore.log
file, and add "log": "/var/log/bookstore.log"
to your configuration file.
Another thing that is useful to some controllers—but not all of them—is the information about the user performing the action. As this is only going to be available for certain routes, we should not set it when constructing the controller. Instead, we have a setter for the router to set the customer ID when available; in fact, the router does that already.
Finally, a handy helper method that we could use is one that renders a given template with parameters, as all the controllers will end up rendering one template or the other. Let's add the following protected method to the AbstractController
class:
protected function render(string $template, array $params): string { return $this->view->loadTemplate($template)->render($params); }
Let's start by creating the easiest of the controllers: the ErrorController
. This controller does not do much; it just renders the error.twig
template sending the "Page not found!" message. As you might remember, the router uses this controller when it cannot match the request to any of the other defined routes. Save the following class in src/Controllers/ErrorController.php
:
<?php namespace BookstoreControllers; class ErrorController extends AbstractController { public function notFound(): string { $properties = ['errorMessage' => 'Page not found!']; return $this->render('error.twig', $properties); } }
The second controller that we have to add is the one that manages the login of the customers. If we think about the flow when a user wants to authenticate, we have the following scenarios:
There are up to four possible paths. We will use the request
object to decide which of them to use in each case, returning the corresponding response. Let's create, then, the CustomerController
class in src/Controllers/CustomerController.php
with the login
method, as follows:
<?php namespace BookstoreControllers; use BookstoreExceptionsNotFoundException; use BookstoreModelsCustomerModel; class CustomerController extends AbstractController { public function login(string $email): string { if (!$this->request->isPost()) { return $this->render('login.twig', []); } $params = $this->request->getParams(); if (!$params->has('email')) { $params = ['errorMessage' => 'No info provided.']; return $this->render('login.twig', $params); } $email = $params->getString('email'); $customerModel = new CustomerModel($this->db); try { $customer = $customerModel->getByEmail($email); } catch (NotFoundException $e) { $this->log->warn('Customer email not found: ' . $email); $params = ['errorMessage' => 'Email not found.']; return $this->render('login.twig', $params); } setcookie('user', $customer->getId()); $newController = new BookController($this->request); return $newController->getAll(); } }
As you can see, there are four different returns for the four different cases. The controller itself does not do anything, but orchestrates the rest of the components, and makes decisions. First, we check if the request is a POST, and if it is not, we will assume that the user wants to get the form. If it is, we will check for the e-mail in the parameters, returning an error if the e-mail is not there. If it is, we will try to find the customer with that e-mail, using our model. If we get an exception saying that there is no such customer, we will render the form with a "Not found" error message. If the login is successful, we will set the cookie with the ID of the customer, and will execute the getAll
method of BookController
(still to be written), returning the list of books.
At this point, you should be able to test the login feature of your application end to end with the browser. Try to access http://localhost:8000/login
to see the form, adding random e-mails to get the error message, and adding a valid e-mail (check your customer
table in MySQL) to log in successfully. After this, you should see the cookie with the customer ID.
The BookController
class will be the largest of our controllers, as most of the application relies on it. Let's start by adding the easiest methods, the ones that just retrieve information from the database. Save this as src/Controllers/BookController.php
:
<?php namespace BookstoreControllers; use BookstoreModelsBookModel; class BookController extends AbstractController { const PAGE_LENGTH = 10; public function getAllWithPage($page): string { $page = (int)$page; $bookModel = new BookModel($this->db); $books = $bookModel->getAll($page, self::PAGE_LENGTH); $properties = [ 'books' => $books, 'currentPage' => $page, 'lastPage' => count($books) < self::PAGE_LENGTH ]; return $this->render('books.twig', $properties); } public function getAll(): string { return $this->getAllWithPage(1); } public function get(int $bookId): string { $bookModel = new BookModel($this->db); try { $book = $bookModel->get($bookId); } catch (Exception $e) { $this->log->error( 'Error getting book: ' . $e->getMessage() ); $properties = ['errorMessage' => 'Book not found!']; return $this->render('error.twig', $properties); } $properties = ['book' => $book]; return $this->render('book.twig', $properties); } public function getByUser(): string { $bookModel = new BookModel($this->db); $books = $bookModel->getByUser($this->customerId); $properties = [ 'books' => $books, 'currentPage' => 1, 'lastPage' => true ]; return $this->render('books.twig', $properties); } }
There's nothing too special in this preceding code so far. The getAllWithPage
and getAll
methods do the same thing, one with the page number given by the user as a URL argument, and the other setting the page number as 1—the default case. They ask the model for the list of books to be displayed and passed to the view. The information of the current page—and whether or not we are on the last page—is also sent to the template in order to add the "previous" and "next" page links.
The get
method will get the ID of the book that the customer is interested in. It will try to fetch it using the model. If the model throws an exception, we will render the error template with a "Book not found" message. Instead, if the book ID is valid, we will render the book template as expected.
The getByUser
method will return all the books that the authenticated customer has borrowed. We will make use of the customerId
property that we set from the router. There is no sanity check here, since we are not trying to get a specific book, but rather a list, which could be empty if the user has not borrowed any books yet—but that is not an issue.
Another getter controller is the one that searches for a book by its title and/or author. This method will be triggered when the user submits the form in the layout template. The form sends both the title
and the author
fields, so the controller will ask for both. The model is ready to use the arguments that are empty, so we will not perform any extra checking here. Add the method to the BookController
class:
public function search(): string { $title = $this->request->getParams()->getString('title'); $author = $this->request->getParams()->getString('author'); $bookModel = new BookModel($this->db); $books = $bookModel->search($title, $author); $properties = [ 'books' => $books, 'currentPage' => 1, 'lastPage' => true ]; return $this->render('books.twig', $properties); }
Your application cannot perform any actions, but at least you can finally browse the list of books, and click on any of them to view the details. We are finally getting something here!
Borrowing and returning books are probably the actions that involve the most logic, together with buying a book, which will be covered by a different controller. This is a good place to start logging the user's actions, since it will be useful later for debugging purposes. Let's see the code first, and then discuss it briefly. Add the following two methods to your BookController
class:
public function borrow(int $bookId): string { $bookModel = new BookModel($this->db); try { $book = $bookModel->get($bookId); } catch (NotFoundException $e) { $this->log->warn('Book not found: ' . $bookId); $params = ['errorMessage' => 'Book not found.']; return $this->render('error.twig', $params); } if (!$book->getCopy()) { $params = [ 'errorMessage' => 'There are no copies left.' ]; return $this->render('error.twig', $params); } try { $bookModel->borrow($book, $this->customerId); } catch (DbException $e) { $this->log->error( 'Error borrowing book: ' . $e->getMessage() ); $params = ['errorMessage' => 'Error borrowing book.']; return $this->render('error.twig', $params); } return $this->getByUser(); } public function returnBook(int $bookId): string { $bookModel = new BookModel($this->db); try { $book = $bookModel->get($bookId); } catch (NotFoundException $e) { $this->log->warn('Book not found: ' . $bookId); $params = ['errorMessage' => 'Book not found.']; return $this->render('error.twig', $params); } $book->addCopy(); try { $bookModel->returnBook($book, $this->customerId); } catch (DbException $e) { $this->log->error( 'Error returning book: ' . $e->getMessage() ); $params = ['errorMessage' => 'Error returning book.']; return $this->render('error.twig', $params); } return $this->getByUser(); }
As we mentioned earlier, one of the new things here is that we are logging user actions, like when trying to borrow or return a book that is not valid. Monolog allows you to write logs with different priority levels: error, warning, and notices. You can invoke methods such as error
, warn
, or notice
to refer to each of them. We use warnings when something unexpected, yet not critical, happens, for example, trying to borrow a book that is not there. Errors are used when there is an unknown problem from which we cannot recover, like an error from the database.
The modus operandi of these two methods is as follows: we get the book
object from the 3database with the given book ID. As usual, if there is no such book, we return an error page. Once we have the book
domain object, we make use of the helpers addCopy
and getCopy
in order to update the stock of the book, and send it to the model, together with the customer ID, to store the information in the database. There is also a sanity check when borrowing a book, just in case there are no more books available. In both cases, we return the list of books that the user has borrowed as the response of the controller.
We arrive at the last of our controllers: the SalesController
. With a different model, it will end up doing pretty much the same as the methods related to borrowed books. But we need to create the sale
domain object in the controller instead of getting it from the model. Let's add the following code, which contains a method for buying a book, add
, and two getters: one that gets all the sales of a given user and one that gets the info of a specific sale, that is, getByUser
and get
respectively. Following the convention, the file will be src/Controllers/SalesController.php
:
<?php namespace BookstoreControllers; use BookstoreDomainSale; use BookstoreModelsSaleModel; class SalesController extends AbstractController { public function add($id): string { $bookId = (int)$id; $salesModel = new SaleModel($this->db); $sale = new Sale(); $sale->setCustomerId($this->customerId); $sale->addBook($bookId); try { $salesModel->create($sale); } catch (Exception $e) { $properties = [ 'errorMessage' => 'Error buying the book.' ]; $this->log->error( 'Error buying book: ' . $e->getMessage() ); return $this->render('error.twig', $properties); } return $this->getByUser(); } public function getByUser(): string { $salesModel = new SaleModel($this->db); $sales = $salesModel->getByUser($this->customerId); $properties = ['sales' => $sales]; return $this->render('sales.twig', $properties); } public function get($saleId): string { $salesModel = new SaleModel($this->db); $sale = $salesModel->get($saleId); $properties = ['sale' => $sale]; return $this->render('sale.twig', $properties); } }