Practical OOP: Building a Quiz App – MVC

Share this article

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!

Frequently Asked Questions (FAQs) about Building a Quiz App with MVC

What is the MVC pattern and why is it important in building a quiz app?

MVC stands for Model-View-Controller. It is a design pattern that separates an application into three interconnected components. The Model represents the data and the business logic, the View displays the data, and the Controller handles the input. The MVC pattern is crucial in building a quiz app because it allows for efficient code organization, making it easier to maintain and scale the application. It also promotes the separation of concerns, meaning different parts of the app can be worked on simultaneously without affecting others.

How does the Slim framework contribute to the MVC structure?

Slim is a PHP micro-framework that assists developers in writing simple yet powerful web applications and APIs. In the context of MVC, Slim can be used as the routing layer that forms part of the Controller component. It handles client requests and routes them to the appropriate controller function. This makes the application more organized and manageable.

How can I maintain a Slim PHP MVC framework with a layered structure?

Maintaining a Slim PHP MVC framework with a layered structure involves keeping the separation of concerns principle. Each layer should have a specific function. For instance, the Model layer should only handle data and business logic, the View layer should only be concerned with displaying data, and the Controller layer should only manage user input. Regular code reviews and refactoring can also help maintain the structure.

How can I use the CodeIgniter framework in building a quiz app?

CodeIgniter is a powerful PHP framework with a small footprint, built for developers who need a simple and elegant toolkit to create full-featured web applications. It can be used in building a quiz app by providing a rich set of libraries for commonly needed tasks, as well as a simple interface and logical structure to access these libraries.

What are some common challenges in building a quiz app with MVC and how can I overcome them?

Some common challenges include managing state between different parts of the application, handling user input, and maintaining a clean and organized codebase. These can be overcome by adhering to the principles of MVC, using a framework like Slim or CodeIgniter, and regularly reviewing and refactoring your code.

How can I add more features to my quiz app?

Adding more features to your quiz app involves understanding the existing codebase and the MVC structure. Once you have a good grasp of these, you can start implementing new features. For instance, you could add a scoring system, a timer, or a leaderboard.

How can I test my quiz app to ensure it’s working correctly?

Testing your quiz app can be done through unit testing, integration testing, and end-to-end testing. Unit testing involves testing individual components of the app, integration testing checks how different parts of the app work together, and end-to-end testing verifies that the app works as a whole from the user’s perspective.

How can I optimize the performance of my quiz app?

Performance optimization can be achieved by minimizing HTTP requests, optimizing images, reducing server response time, enabling compression, minifying resources, and implementing caching strategies.

How can I make my quiz app more interactive and engaging for users?

Making your quiz app more interactive and engaging can be done by adding features like a scoring system, a timer, a leaderboard, and social sharing options. You can also improve the user interface and user experience by making the app visually appealing and easy to use.

How can I secure my quiz app from potential threats?

Securing your quiz app involves implementing measures like data validation, SQL injection prevention, cross-site scripting (XSS) prevention, and using secure protocols for data transmission. Regularly updating your application and its dependencies can also help keep it secure.

Moshe TeutschMoshe Teutsch
View Author

Moshe Teutsch is a freelance web developer and entrepreneur. He specializes in PHP programming and is currently available for hire. In his spare time he enjoys playing chess, singing, writing, reading, philosophizing, and coding in esoteric programming languages.

BrunoSdomain modelmvcOOPHPPHPquizslimSOLID
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week