PHP
Article

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>

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!

  • Dante

    Jesus, so many errors and bad programing practices in this article…
    Where to start. Missing self:: (double semicolon) in half of the implementations. function checkSolution is missing “;” at the end..and so on…

    $_SESSION[self::CURRENT_QUESTION] = $_SESSION[self::CURRENT_QUIZ] = null;

    really??yes it works but really?

    • Moshe Teutsch

      Yes, unfortunately a few typos have found there way into the article. I hope they will be fixed soon. There’s a Github repository containing the full code for both parts of the series that will be linked to the article, and it doesn’t have any such mistakes, as far as I know.

      Regarding the double assignment on one line: it’s simply a matter of taste. Every programmer has his own style, and I think many people find this style just as readable.

      • Eterad

        Regardless the deadline you had you should check the code in article against one you wrote before, as I don’t think editors have time to check every code if it’s working or not.

        Eg.: following your article and using for the first time Slim I didn’t know that there’s something missing here with the variable names you used:

        $app->get(‘/choose-quiz/:id’, function() use ($service, $app) {

        $service->startQuiz($id);

        Also you are referring to your GitHub and i don’t see any link to it in the article or comments. So how should I know that you have account at GitHub and that there’s probably working code from this article. If you don’t check for code errors in article then put direct mention of GitHub then there should be less if’s and why’s.

      • Dante

        Every time I see this or similar construct in code my first reaction is (to the programer) “You little egoistic piece of ****”. You know how harder it is to bug hunt thru code with stuff like this in it? So even if you think it’s awesome have some courtesy to people reading your code.

        This is also perfectly valid: $a = ($b) ? ($c) ? $e : $f : $d;
        But everyone bug hunting thru your code will hate you because of it…

    • http://www.bitfalls.com/ Bruno Skvorc

      Sorry about the typos, fixed

      • Dante

        Still left Bruno: foreach ($quizes as $quizes) (choose-quiz.phtml)…also in the first part of the article in composer.json psr-O should be psr-0 …

        • http://www.bitfalls.com/ Bruno Skvorc

          Gah. Sorry about this mess. I’m upping the discipline from now on.

          • Dante

            please do :) I’m not used to such clumsy articles under your “rule”…

  • david2m

    I didn’t know people were still using the $_SESSION superglobal directly. Put some sort of a wrapper around it at least to help with unit testing.

    • Moshe Teutsch

      I actually addressed your concern in the article. I felt that creating my own wrapper in this toy-project would be off-point. Here’s the quote from the article:

      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.

      • david2m

        I only skimmed through it and never saw that. My mistake.

  • Strider

    I think this is a great way to learn MVC, but not if you want to develop a quiz that is interactive with the player. I develop a quiz game using PHP, AJAX and JQuery that doesn’t reload the page for the next question. Actually the php portion is relatively simple, retrieve the question/answers or same the question/answer if you allowing new questions to be entered. In my opinion not everything has to be done in PHP OOP to get the results you want and yes to answer your question that you might be asking yourself. I do know OOP in PHP.

    • Moshe Teutsch

      Hey Strider,

      The whole idea of separating the business logic of the application from the controller code is that you can easily re-use it for different interfaces, such as an AJAX, no-reload user-interface. You can easily add a RESTful API in very few lines, which you can then use from the client-side to create kind of app your talking about.

      Regards,
      Moshe

  • anagnam

    Moshe, is it possible in today’s technology to write cutting edge php apps (without ever using any frameworks and) with just plain php?

    • Moshe Teutsch

      Probably not, unless you’re a giant like Facebook. Frameworks give you the foundation on which it’s easy to quickly develop secure, SOLID apps. Even using a thin MVC layer, such as Slim, as we did in this article, speeds up development, eases maintenance, and gives you lots of flexibility in the future to take advantage of the framework’s features or add-ons. No use re-inventing the wheel.

  • Carlos A Loaiza O

    There is an error on setup mongo db at line 2, the correct statement is :

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

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in PHP, once a week, for free.