Immutable MVC in PHP (Part 2) - Immutable CRUD application
The Hello World example in part 1 worked, but it's a little basic for fully demonstrating the concept.
Note: the complete code for this article is available over at github
In this article, I'm going to build an immutable MVC CRUD website with the following functionality:
- Adding/Editing Jokes to/on the website
- Listing Jokes that have been added
- Sorting jokes on the list page newest first or oldest first
- Searching for a joke based on a keyword
The following examples don't use ORMs, template systems or anything other than bare bones PHP. Although you would want to use these things in a real application, I want to keep the focus here on the MVC concept, and not add complexity by introducing other libraries or abstractions. I want to stress which parts of the code go where without getting bogged down into specific ORM or Template engine implementations.
Model focused MVC
There are two different pages on this jokes website. If you remember back to my Model-View-Confusion series, one of the main complaints I have about the MVC structure that's used on the web is that controllers do too much. In most frameworks a controller will contain methods like the following: listAction()
, editFormAction()
, editSubmitAction()
, sortJokesAction()
.
Most "MVC" implementations do the following:
class JokeController {
public function editFormAction() {
}
public function addSubmitAction() {
}
public function editSubmitAction() {
}
public function listAction() {
}
public function sortListAction() {
}
public function searchAction() {
}
}
I've argued against this approach on several occasions and want to re-affirm my point here.
Unlike most "MVC" implementations, each page should have its own controller, view and model. I will use this as an excuse to make the case for this approach again, in a more complete way than I did in the Model-View-Confusion article nearly ten years ago.
I'm going to use a similar approach to the other articles on this site where the view fetches its own data from the model, only this time I'll be making each component immutable using the approach outlined in part 1 where the model is passed into each controller action.
Using this approach there will be two controllers, two models and two views.
One page for adding/editing which has the actions:
- display the empty form/load a record into the form
- submit the form, displaying errors or saving to the database
And another page for the list page with the actions:
- filter/sort the list
- delete a joke
List page model
Let's do the list page first. Firstly, we'll start with the model. Always start with the model. Fun fact: The model is the most important part in MVC which is why it's first and why MVC frameworks that put most of the work in the controller don't get it right! Here's an immutable model that allows fetching jokes from a database with a few options for the jokes that are retrieved:
namespace JokeSite;
class JokeList {
/* database connection */
private $pdo;
/* sort method */
private $sort = 'oldest';
/* search keywork if set */
private $keyword;
public function __construct(\PDO $pdo, string $sort = 'oldest', string $keyword = '') {
$this->pdo = $pdo;
$this->sort = $sort;
$this->keyword = $keyword;
}
public function sort($dir): self {
return new self($this->pdo, $dir, $this->keyword);
}
public function search($keyword): self {
return new self($this->pdo, $this->sort, $keyword);
}
public function getKeyword(): string {
return $this->keyword;
}
public function getSort(): string {
return $this->sort;
}
public function delete($id): self {
$stmt = $this->pdo->prepare('DELETE FROM joke WHERE id = :id');
$stmt->execute(['id' => $id]);
return $this;
}
public function getJokes(): array {
$parameters = [];
if ($this->sort == 'newest') {
$order = ' ORDER BY id DESC';
}
else if ($this->sort == 'oldest') {
$order = ' ORDER BY id ASC';
}
else {
$order = '';
}
if ($this->keyword) {
$where = ' WHERE text LIKE :text';
$parameters['text'] = '%' . $this->keyword . '%';
}
else {
$where = '';
}
$stmt = $this->pdo->prepare('SELECT * FROM joke ' . $where . $order);
$stmt->execute($parameters);
return $stmt->fetchAll();
}
}
Using the list page model
Ok, it's not quite immutable because the PDO object can be modified externally, breaking this class if $pdo->beginTransaction()
or similar is called elsewhere in the application. However, cloning the database connection has some major drawbacks so we'll have to put up with it for this example.
As stated in the intro, I don't want to introduce ORMs or anything in this article and want to keep it as simple as possible. The model just queries the database directly.
There's a lot going on here, but this immutable class provides the business logic for the joke list page. The getJokes
method returns the list of jokes that are currently represented by the model. On it's own the model could be used like this:
$model = new \JokeSite\JokeList($pdo);
// Fetch all jokes from the database
$jokes = $model->getJokes();
//Sort the jokes newest first
$model = $model->sort('newest');
$jokes = $model->getJokes();
//Sort newest first and search for jokes containing the word `programmer`
$model = $model->sort('newest')->search('programmer')
$jokes = $model->getJokes();
//Delete the record with the ID 123
$model = $model->delete(123);
List page View
On to the view, the view for the list pages needs to list the jokes currently represented by the model, provide options for sorting and searching as well as links for deleting, adding or editing jokes. Note that add/edit functionality is its own view and therefore will have its own controller and model.
namespace JokeList;
class View {
public function output(\JokeSite\JokeList $model): string {
$output = '
<p><a href="index.php?route=edit">Add new joke</a></p>
<form action="" method="get"><input type="hidden" value="filterList" name="route"/><input type="hidden" value="' . $model->getSort() . '" name="sort"/><input type="text" placeholder="Enter search text" name="search"/><input type="submit" value="submit"/></form>
<p>Sort: <a href="index.php?route=filterList&sort=newest">Newest first</a> | <a href="index.php?route=filterList&sort=oldest">Oldest first</a></p>
<ul>
';
foreach ($model->getJokes() as $joke) {
$output .= '<li>' . $joke['text'];
$output .= ' <a href="index.php?route=edit&id=' . $joke['id'] . '">Edit</a>';
$output .= '<form action="index.php?route=delete" method="POST"><input type="hidden" name="id" value="' . $joke['id'] . '"/><input type="submit" value="Delete"/></form>';
$output .= '</li>';
}
$output .= '</ul>';
return $output;
}
}
List page controller
The controller's job is to take the user input and update the model. I'm using the same approach used in part 1 where the immutable model is passed to the controller as an argument and an updated instance is returned.
namespace JokeList;
class Controller {
public function filterList(\JokeSite\JokeList $jokeList): \JokeSite\JokeList {
if (!empty($_GET['sort'])) {
$jokeList = $jokeList->sort($_GET['sort']);
}
if (!empty($_GET['search'])) {
$jokeList = $jokeList->search($_GET['search']);
}
return $jokeList;
}
public function delete(\JokeSite\JokeList $jokeList): \JokeSite\JokeList {
return $jokeList->delete($_POST['id']);
}
}
Because sorting and filtering can be applied at the same time, I've put them in the same action. Eventually I plan on showing how an Application Server can be used to have stateful models that retain state between requests. For now, we'll need to take all user input and use it to set a fresh model's state on each request.
Putting it all together
Let's put all of this together. We'll need a router. For now, just a basic if statement using $_GET['route']
$pdo = new \Pdo('mysql:host=v.je;dbname=ijdb', 'student', 'student');
$model = new \JokeSite\JokeList($pdo);
$controller = new \JokeList\Controller();
$view = new \JokeList\View();
if (isset($_GET['route'])) {
$action = $_GET['route'];
$model = $controller->$action($model);
}
echo $view->output($model);
Clearly this will need to be amened to support different controllers but that's it for the list page. I'll add some proper routing logic after we have controllers for the form.
Add/Edit form
In addition to the joke list page, there is a second page for adding/editing records. It's time to implement that and as usual, let's start with the model:
namespace JokeSite;
class JokeForm {
private $pdo;
/* $submitted: Whether or not the form has been submitted */
private $submitted = false;
/* Validation errors of submitted data */
private $errors = [];
/* The record being represented. May come from the database or a form submission */
private $record = [];
public function __construct(\PDO $pdo, $submitted = false, array $record = [], array $errors = []) {
$this->pdo = $pdo;
$this->record = $record;
$this->submitted = $submitted;
$this->errors = $errors;
}
/*
* @description load a record from the database
* @param $id - ID of the record to load from the database
*/
public function load(int $id): JokeForm {
$stmt = $this->pdo->prepare('SELECT * FROM joke WHERE id = :id');
$stmt->execute(['id' => $id]);
$record = $stmt->fetch();
return new JokeForm($this->pdo, $this->submitted, $record);
}
/*
* @description return the record currently being represented
* this may have come from the DB or $_POST
*/
public function getJoke(): array {
return $this->record;
}
/*
* @description has the form been submitted or not?
*/
public function isSubmitted(): bool {
return $this->submitted;
}
/*
* @description return a list of validation errors in the current $record
*/
public function getErrors(): array {
return $this->errors;
}
/*
* @description attempt to save $record to the database, insert or update
* depending on whether $record['id'] is set
*/
public function save(array $record): JokeForm {
$errors = $this->validate($record);
if (!empty($errors)) {
// Return a new instance with $record set to the form submission
// When the view displays the joke, it will display the invalid
// form submission back in the box
return new JokeForm($this->pdo, true, $record, $errors);
}
if (!empty($record['id'])) {
return $this->update($record);
}
else {
return $this->insert($record);
}
}
/*
* @description validates $record
*/
private function validate(array $record): array {
$errors = [];
if (empty($record['text'])) {
$errors[] = 'Text cannot be blank';
}
return $errors;
}
/*
* @description save the record using an UPDATE query
*/
private function update(array $record): JokeForm {
$stmt = $this->pdo->prepare('UPDATE joke SET text = :text WHERE id = :id');
$stmt->execute($record);
return new JokeForm($this->pdo, true, $record);
}
/*
* @description save the record using an INSERT query
*/
private function insert(array $record): JokeForm {
$stmt = $this->pdo->prepare('INSERT INTO joke (text) VALUES(:text)');
$stmt->execute(['text' => $record['text']]);
$record['id'] = $this->pdo->lastInsertId();
return new JokeForm($this->pdo, true, $record);
}
}
Form model API
There's a lot going on here, but it contains the following public API:
save(array $record)
- Save a record to the database (either update or insert)isSubmitted()
- Has the form been submittedload(int $id)
- Load a record into the model from the databasegetJoke()
- Get the joke currently being edited (can be an empty array) if no joke is being editedgetErrors()
- Any validation errors after form submission
Add/edit form View
As it should be in MVC, the model contains a majority of the code but contains no display logic. Let's add the view:
namespace JokeForm;
class View {
public function output(\JokeSite\JokeForm $model): string {
$errors = $model->getErrors();
if ($model->isSubmitted() && empty($errors)) {
//On success, redirect to list page
header('location: index.php');
die;
}
$joke = $model->getJoke();
$output = '';
if (!empty($errors)) {
$output .= '<p>The record could not be saved:</p>';
$output .= '<ul>';
foreach ($errors as $error) {
$output .= '<li>' . $error . '</li>';
}
$output .= '</ul>';
}
$output .= '<form action="" method="post">'
. '<input type="hidden" value="' . ($joke['id'] ?? ''). '" name="joke[id]"/>'
. '<textarea name="joke[text]">' . ($joke['text'] ?? '') . '</textarea>'
. '<input type="submit" value="submit"/>'
. '</form>';
return $output;
}
}
The view displays the form, if there is a record loaded into the model, it displays the model in the form, otherwise it displays a blank form. If there are any validation errors, they are also displayed.
Notice that the redirect on success is in the view layer. This is display logic ("Display the form or a success page if the form was correctly submitted") and not part of the controller as is common in pseudo-MVC frameworks.
Add/edit form Controller
The final piece of the add/edit form is the controller:
namespace JokeForm;
class Controller {
public function edit(\JokeSite\JokeForm $jokeForm): \JokeSite\JokeForm {
if (isset($_GET['id'])) {
return $jokeForm->load($_GET['id']);
}
else {
return $jokeForm;
}
}
public function submit(\JokeSite\JokeForm $jokeForm): \JokeSite\JokeForm {
return $jokeForm->save($_POST['joke']);
}
}
This takes the user input and loads a joke into the model if an ID is set. When the view reads the record from the model, it will either receive an empty array or the record that was loaded by the controller.
Routing
Finally we'll a very crude router using a simple if/else. The next article will tidy this up. In this article I just wanted to demonstrate a completely immutable MVC application with multiple models, views and controllers.
For now, this example uses index.php?route={ROUTE}
for specifying the route. In the next example I'll tidy this up but the focus of this article is not routing, the next one will cover that in detail.
$pdo = new \Pdo('mysql:host=v.je;dbname=' . DB_SCHEMA, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$route = $_GET['route'] ?? '';
if ($route == '') {
$model = new \JokeSite\JokeList($pdo);
$view = new \JokeList\View();
}
else if ($route == 'edit') {
$model = new \JokeSite\JokeForm($pdo);
$controller = new \JokeForm\Controller();
$model = $controller->edit($model);
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$model = $controller->submit($model);
}
$view = new \JokeForm\View();
}
else if ($route == 'delete') {
$model = new \JokeSite\JokeList($pdo);
$controller = new \JokeList\Controller();
$model = $controller->delete($model);
$view = new \JokeList\View();
}
else if ($route == 'filterList') {
$model = new \JokeSite\JokeList($pdo);
$view = new \JokeList\View();
$controller = new \JokeList\Controller();
$model = $controller->filterList($model);
}
else {
http_response_code(404);
echo 'Page not found (Invalid route)';
}
echo $view->output($model);
N.b. I've omitted the autoloader in this example but the full code is available over at github.
The router takes $_GET['route']
and maps it to a controller action. I've also partially implemented REST here. This router is not ideal and needs some work. This just shows the basic implementation of MVC, with immutability and how it can work in real world examples.
Conclusion
In this article I showed you how to create a comletely immutable CRUD application using MVC. You'll have noticed that when using this approach most of the code ends up in the model. This is a good thing! The model could be reused on pages that display as a PDF, JSON for Ajax calls, etc.
In the next article I'll show you how to implement a better router into this code than the big if-else statement we have currently.