Tom Butler's programming blog

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 $pdostring $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->pdotrue$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->pdotrue$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->pdotrue$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 submitted
  • load(int $id) - Load a record into the model from the database
  • getJoke() - Get the joke currently being edited (can be an empty array) if no joke is being edited
  • getErrors() - 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_SCHEMADB_USERDB_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.