PHP
Article
By Moshe Teutsch

Practical OOP: Building a Quiz App – MVC

By Moshe Teutsch

Building a SOLID MVC Quiz App with Slim

In part one of this series we began, using the bottom-up design approach, by creating our Quiz and Question entities, writing a dummy Data Mapper class for the Quiz entities, and sketching the interface for our main service, \QuizApp\Service\Quiz, which will define the flow for a user solving a quiz. If you haven’t yet read the first part, I suggest you quickly skim through it before continuing with part two and/or download the code from here.

This time we’ll create and flesh out the \QuizApp\Service\Quiz service that will be the backbone of our quiz app. We’ll then write our controllers and views using the Slim MVC framework, and, finally, create a MongoDB mapper to take the place of the dummy mapper we wrote last time.

Coding the Service:

Okay, now that we’ve defined the interface for the mapper and created the entity classes, we have all the building blocks we need for implementing a concrete service class.

<?php

namespace QuizApp\Service;

use QuizApp\Service\Quiz\Result;

// ...

class Quiz implements QuizInterface
{
    const CURRENT_QUIZ = 'quizService_currentQuiz';
    const CURRENT_QUESTION = 'quizService_currentQuestion';
    const CORRECT = 'quizService_correct';
    const INCORRECT = 'quizService_incorrect';

    private $mapper;

    public function __construct(\QuizApp\Mapper\QuizInterface $mapper)
    {
        $this->mapper = $mapper;
    }

    /** @return Quiz[] */
    public function showAllQuizes()
    {
        return $this->mapper->findAll();
    }

    public function startQuiz($quizOrId)
    {
        if (!($quizOrId instanceof \QuizApp\Entity\Quiz)) {
            $quizOrId = $this->mapper->find($quizOrId);
            if ($quizOrId === null) {
                throw new \InvalidArgumentException('Quiz not found');
            }
        }

        $_SESSION[self::CURRENT_QUIZ] = $quizOrId->getId();
        $_SESSION[self::CORRECT] = $_SESSION[self::INCORRECT] = 0;
    }

    /**
     * @return Question
     * @throws \LogicException
     */
    public function getQuestion()
    {
        $questions = $this->getCurrentQuiz()->getQuestions();
        $currentQuestion = $this->getCurrentQuestionId();
        if ($this->isOver()) {
            throw new \LogicException();
        }
        return $questions[$currentQuestion];
    }

    /** @return bool */
    public function checkSolution($solutionId)
    {
        $result = $this->getQuestion()->isCorrect($solutionId);
        $_SESSION[self::CURRENT_QUESTION] = $this->getCurrentQuestionId() + 1;
        $this->addResult($result);
        if ($this->isOver()) {
            $_SESSION[self::CURRENT_QUESTION] = $_SESSION[self::CURRENT_QUIZ] = null;
        }
        return $result;
    }

    /** @return bool */
    public function isOver()
    {
        try {
            return $this->getCurrentQuestionId() >= count($this->getCurrentQuiz()->getQuestions());
        } catch (\LogicException $e) {
            return true;
        }
    }

    /** @return Result */
    public function getResult()
    {
        return new Result(
            $_SESSION[self::CORRECT], $_SESSION[self::INCORRECT],
            ($_SESSION[self::CORRECT] + $_SESSION[self::INCORRECT]) / 2
        );
    }

    private function getCurrentQuiz()
    {
        if (!isset($_SESSION[self::CURRENT_QUIZ])) {
            throw new \LogicException();
        }
        $quiz = $this->mapper->find($_SESSION[self::CURRENT_QUIZ]);
        if ($quiz === null) {
            throw new \LogicException();
        }
        return $quiz;
    }

    private function getCurrentQuestionId()
    {
        return isset ($_SESSION[self::CURRENT_QUESTION]) ? $_SESSION[self::CURRENT_QUESTION] : 0;
    }

    private function addResult($isCorrect)
    {
        $type = ($isCorrect ? self::CORRECT : self::INCORRECT);
        if (!isset($_SESSION[$type])) {
            $_SESSION[$type] = 0;
        }
        $_SESSION[$type] += 1;
    }
}

That’s a long one. Let’s go over it method by method.

The showAllQuizes() method wraps the QuizMapper::findAll() method. We could make $mapper public, but that would break encapsulation, leaking low-level operations to high-level classes.

The startQuiz() method begins the quiz that is passed as an argument by storing the quiz in the session for future reference. It accepts either a quiz entity object or a quiz ID. In the latter case it tries to find the quiz using the $mapper. The method uses the $_SESSION superglobal directly, which isn’t best practice–the service would break if used in a command-line context, for instance–but there’s no need to over-complicate the service yet. If we wanted to make the service runnable on the command-line, we’d extract the operations we used for storing data in the session to an interface. The web controller would pass an implementation that internally used the $_SESSION superglobal, while the command-line controller might store the “session” variables in as class properties.

The getQuestion() method tries getting the next question of the current quiz from the database, delegating to other helpful methods, and throws an exception if the quiz is over or the user isn’t in the middle of a quiz. The checkSolution() method returns whether the user’s solution is correct, and updates the session to reflect the state of the quiz after the question is answered.

The isOver() method returns true if the current quiz is over or if no quiz is underway.

The getResult() method returns a \QuizApp\Service\Quiz\Result object that tells the user whether he passed the quiz and how many questions he answered correctly.

Controllers and Views with Slim:

Now that we’ve finished setting up the “M” of our MVC application, it’s time to write our controllers and views. We’re using the Slim framework, but it’s easy to replace Slim with any other MVC framework as our code is decoupled. Create an index.php file wih the following contents:

<?php

require 'vendor/autoload.php';

session_start();

$service = new \QuizApp\Service\Quiz(
    new \QuizApp\Mapper\HardCoded()
);
$app = new \Slim\Slim();
$app->config(['templates.path' => './views']);
// Controller actions here
$app->run();

This is the base of our Slim application. We create our service and start the PHP session, since we use the $_SESSION superglobal in our service. Finally, we set up our Slim application. For more information about Slim, read the extensive documentation on the Slim project website.

Let’s create the homepage first. The homepage will list the quizzes the user can take. The controller code for this is straightforward. Add the following by the comment in our index.php file.

$app->get('/', function () use ($service, $app) {
    $app->render('choose-quiz.phtml', [
        'quizes' => $service->showAllQuizes()
    ]);}
);

We define the homepage route with the $app->get() method. We pass the route as the first parameter and pass the code to run as the second parameter, in the form of an anonymous function. In the function we render the choose-quiz.phtml view file, passing to it the list of our quizzes we retrieved from the service. Let’s code the view.

<h3>choose a quiz</h3>
    <ul>
        <?php foreach ($quizes as $quiz) : ?>
            <li><a href="choose-quiz/<?php echo $quiz->getId();?>"><?php  echo $quiz->getTitle(); ?></a></li>
        <?php endforeach; ?>
    </ul>

At this point, if you navigate to the home page of the app with your browser, you’ll see the two quizes we hard-coded earlier, “Quiz 1” and “Quiz 2.”
The quiz links on the home page point to choose-quiz/:id where :id is the ID of the quiz. This route should start the quiz that the user chose and redirect him to his first question. Add the following route to index.php:

$app->get('/choose-quiz/:id', function($id) use ($service, $app) {
        $service->startQuiz($id);
        $app->redirect('/solve-question');
    });

Now let’s define the /solve-question route. This route will show the user the current question of the quiz he is solving.

$app->get('/solve-question', function () use ($service, $app) {
        $app->render('solve-question.phtml', [
            'question' => $service->getQuestion(),
        ]);
    }
);

The route renders the view solve-question.phtml with the question returned from the service. Let’s define the view.

<h3><?php echo $question->getQuestion(); ?></h3>
<form action="check-answer" method="post">
    <ul>
        <?php foreach ($question->getSolutions() as $id => $solution): ?>
        <li><input type="radio" name="id" value="<?php echo $id; ?>"> <?php echo $solution; ?></li>
        <?php endforeach; ?>
    </ul>
    <input type="submit" value="submit">
</form>

We show the user a form with a radio button per answer. The form sends the results to the check-answer route.

$app->post('/check-answer', function () use ($service, $app) {
        $isCorrect = $service->checkSolution($app->request->post('id'));
        if (!$service->isOver()) {
            $app->redirect('/solve-question');
        } else {
            $app->redirect('/end');
        }
    });

This time we’re defining a route for “POST” requests, so we use the $app->post() method. To get the solution ID sent by the user we call $app->request->post('id'). The service returns whether this answer was correct. If there are more questions to answer, we redirect him back to the “solve-question” route. If he’s finished the quiz, we send him to the “end” route. This should tell the user whether he passed the quiz and how many questions he answered correctly.

$app->get('end', function () use ($service, $app) {
        $app->render('end.phtml', [
            'result' => $service->getResult(),
        ]);
    });

We do this by retrieving a \QuizApp\Service\Quiz\Result object from the service and passing it to the view.

<?php if ($result->hasPassed()) : ?>
    <h3>You passed!</h3>
<?php else: ?>
    <h3>You failed!</h3>
<?php endif; ?>
<p>You got <?php echo $result->getCorrect(); ?> out of <?php echo $result->getTotal(); ?> questions right.</p>
<a href="/">Back to quizes</a>
--ADVERTISEMENT--

Writing a Real Mapper with MongoDB:

At this point the app is complete, and will run properly–but we should write a real \QuizApp\Mapper\QuizInterface instance to connect to MongoDB. Right now we’re fetching our quizes from our Hardcoded mapper.

Install MonogoDB if you don’t have it installed already.

We need to create a database, a collection, and propagate the collection with a dummy quiz. Run Mongo–mongo – and inside the terminal run the following commands:

> use practicaloop
    > db.quizes.insert((
          title: 'First Quiz',
          questions: [{
              question: 'Who\'s buried in Grant\'s tomb?',
              solutions: ['Jack', 'Joe', 'Grant', 'Jill'],
              correctIndex: 2
          }]
      })

Now we need to write another mapper that implements QuizInterface.

<?php

namespace QuizApp\Mapper;

class Mongo implements QuizInterference
{
    private static $MAP = [];

    /** @var \MongoCollection */
    private $collection;

    public function __construct(\MongoCollection $collection)
    {
        $this->collection = $collection;
    }

    /**
     * @return \QuizApp\Entity\Quiz[]
     */
    public function findAll()
    {
        $entities = [];
        $results = $this->collection->find();
        foreach ($results as $result) {
            $entities[] = $e = $this->rowtoEntity($result);
            $this->cacheEntity($e);
        }
        return $entities;
    }

    /**
     * @param int $id
     * @return \QuizApp\Entity\Quiz
     */
    public function find($id)
    {
        $id = (string) $id;
        if (isset(self::$MAP[$id])) {
            return self::$MAP[$id];
        }
        $row = $this->collection->findOne(['_id' => new \MongoId($id)]);
        if ($row === null) {
            return null;
        }
        $entity = $this->rowtoEntity($row);
        $this->cacheEntity($entity);
        return $entity;
    }

    private function cacheEntity($entity)
    {
        self::$MAP[(string) $entity->getId()] = $entity;
    }

    private function rowToEntity($row)
    {
        $result = new \QuizApp\Entity\Quiz(
            $row['title'],
            array_map(function ($question) {
                return new \QuizApp\Entity\Question(
                    $question['question'],
                    $question['solutions'],
                    $question['correctIndex']
                );
            }, $row['questions'])
        );
        $result->setId($row['_id']);
        return $result;
    }
}

Let’s see what’s going on here. The class accepts a \MongoCollection as a constructor parameter. It then uses the collection to retrieve rows from the datbase in the find() and findAll() methods. Both methods follow the same steps: retrieve the row or rows from the database, convert the rows into our \QuizApp\Entity\Quiz and \QuizApp\Entity\Question objects, and caches them internally to avoid having to look up the same entities later.

All we have left to do is pass an instance of the new mapper to our service in the index.php file.

Conclusion:

In this series, we built an MVC web application using the Service Layer and Domain Model design patterns. By doing so we followed MVC’s “fat model, thin controller” best practice, keeping our entire controller code to 40 lines. I showed you how to create an implementation-agnostic mapper for accessing the database, and we created a service for running the quiz regardless of the user-interface. I’ll leave it to you to create a command-line version of the application. You can find the full source for this part here.

Comments? Questions? Leave them below!

Recommended
Sponsors
The most important and interesting stories in tech. Straight to your inbox, daily. Get Versioning.
Login or Create Account to Comment
Login Create Account