Back to Course |
Creating a Quiz System with Laravel 10 + Livewire 3: Step-by-Step

Page for Taking a Quiz

In this tutorial, we will make a page where users will finally be able to take quizzes.

quiz with question and options

Let's start by adding a show method in the HomeController and adding a route with the view.

app/Http/Controllers/HomeController.php:

class HomeController extends Controller
{
// ...
public function show(Quiz $quiz, $slug = null)
{
return view('front.quizzes.show', compact( 'quiz'));
}
}

routes/web.php:

Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('quiz/{quiz}/{slug?}', [HomeController::class, 'show'])->name('quiz.show');
// ...

Next, we need to add this route for quizzes on the homepage.

resources/views/home.blade.php:

// ...
<a href="{{ route('quiz.show', [$quiz, $quiz->slug]) }}">{{ $quiz->title }}</a>
// ...

Now for showing the quiz first we need to make a check if the quiz isn't public and the user isn't a guest, because we don't want to guests to take quizzes that are only for registered users. First, create a new view file resources/views/front/quizzes/show.blade.php.

resources/views/front/quizzes/show.blade.php:

<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $quiz->title }}
</h2>
</x-slot>
 
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
@if (! $quiz->public && ! auth()->check())
<div class="text-white px-6 py-4 border-0 rounded relative mb-4 bg-indigo-500">
<span class="inline-block align-middle mr-8">
This test is available only for registered users. Please <a href="{{ route('login') }}" class="hover:underline">Log in</a> or <a href="{{ route('register') }}" class="hover:underline">Register</a>
</span>
</div>
@else
The livewire component will go here
@endif
</div>
</div>
</div>
</div>
</x-app-layout>

If the guest will visit such a quiz page they will see a message like below:

quiz only for registered users

Next, we need to create a Livewire component where all the logic for showing questions will be.

php artisan make:livewire Front/Quizzes/Show

And add a component to the quizzes show view.

resources/views/front/quizzes/show.blade.php:

// ...
@if (! $quiz->public && ! auth()->check())
<div class="text-white px-6 py-4 border-0 rounded relative mb-4 bg-indigo-500">
<span class="inline-block align-middle mr-8">
This test is available only for registered users. Please <a href="{{ route('login') }}" class="hover:underline">Log in</a> or <a href="{{ route('register') }}" class="hover:underline">Register</a>
</span>
</div>
@else
<livewire:front.quizzes.show :quiz="$quiz" />
@endif
// ...

In the Livewire component let's start by adding public properties.

app/Livewire/Front/Quizzes/Show.php:

use App\Models\Quiz;
use Livewire\Component;
use App\Models\Question;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
 
class Show extends Component
{
public Quiz $quiz;
 
public Collection $questions;
 
public Question $currentQuestion;
 
public int $currentQuestionIndex = 0;
 
public array $questionsAnswers = [];
 
public int $startTimeSeconds = 0;
 
public function render(): View
{
return view('livewire.front.quizzes.show');
}
}

So what every property will have here?

  • $quiz for current taken Quiz.
  • $questions a Collection of questions for the Quiz.
  • $currentQuestion will have a current question that needs to be answered on the screen.
  • $currentQuestionIndex is the key number of which Question to show.
  • $questionsAnswers is where users selected answers will be set.
  • $startTimeSeconds will have the time in seconds when the quiz was started.

Next, we will add the mount method.

app/Livewire/Front/Quizzes/Show.php:

class Show extends Component
{
// ...
 
public function mount(): void
{
$this->startTimeSeconds = now()->timestamp;
 
$this->questions = Question::query()
->inRandomOrder()
->whereRelation('quizzes','id', $this->quiz->id)
->with('questionOptions')
->get();
 
$this->currentQuestion = $this->questions[$this->currentQuestionIndex];
 
for($i = 0; $i < $this->questionsCount; $i++) {
$this->questionsAnswers[$i] = [];
}
}
 
// ...
}

So, what do we set when the Livewire component gets mounted?

  • First, we set the Quiz start time in seconds.
  • Then, we get the Questions for the Quiz in random order and eager load Options for the Questions.
  • Next, we set the current Question.
  • And last, we make an empty array for all Questions answers from the Questions count.

As you can see for the questions count instead of $this->questions->count() we use $this->questionsCount. Here we use a computed property which gets cached and we can reuse its value.

app/Livewire/Front/Quizzes/Show.php:

use Livewire\Attributes\Computed;
 
class Show extends Component
{
// ...
 
public function mount(): void
{
$this->startTimeSeconds = now()->timestamp;
 
$this->questions = Question::query()
->inRandomOrder()
->whereRelation('quizzes', 'id', $this->quiz->id)
->with('questionOptions')
->get();
 
$this->currentQuestion = $this->questions[$this->currentQuestionIndex];
 
for($i = 0; $i < $this->questionsCount; $i++) {
$this->questionsAnswers[$i] = [];
}
}
 
#[Computed]
public function questionsCount(): int
{
return $this->questions->count();
}
 
// ...
}

Now we can start showing questions for the user in the quiz.

resources/views/livewire/front/quizzes/show.blade.php:

<div>
<span class="text-bold">Question {{ $currentQuestionIndex + 1 }} of {{ $this->questionsCount }}:</span>
<h2 class="mb-4 text-2xl">{{ $currentQuestion->question_text }}</h2>
 
@if ($currentQuestion->code_snippet)
<pre class="mb-4 border-2 border-solid bg-gray-50 p-2">{{ $currentQuestion->code_snippet }}</pre>
@endif
 
@foreach($currentQuestion->questionOptions as $option)
<div>
<label for="option.{{ $option->id }}">
<input type="radio"
id="option.{{ $option->id }}"
wire:model="questionsAnswers.{{ $currentQuestionIndex }}"
name="questionsAnswers.{{ $currentQuestionIndex }}"
value="{{ $option->id }}">
{{ $option->option }}
</label>
</div>
@endforeach
 
@if ($currentQuestionIndex < $this->questionsCount - 1)
<div class="mt-4">
<x-secondary-button>
Next question
</x-secondary-button>
</div>
@else
<div class="mt-4">
<x-primary-button wire:click="submit">Submit</x-primary-button>
</div>
@endif
</div>

Nothing special we do in this view file. Just showing some information like the number of the question of total questions. If there is a code snippet then show it. Going through current question options and showing them, binding to the $questionsAswers property. And the last thing, if there are more questions, we show the Next question button otherwise we show the Submit button.

After visiting the quiz you should see a similar result below:

quiz with question and options

Now that users can see questions, we need to make buttons work. First, for the Next question button, let's add the changeQuestion action.

resources/views/livewire/front/quizzes/show.blade.php:

// ...
<div class="mt-4">
<x-secondary-button wire:click="changeQuestion">
Next question
</x-secondary-button>
</div>
// ...

In the changeQuestion method, we need to add +1 for the $currentQuestionIndex and set the $currentQuestion to the question from the $questions where the key would be $currentQuestionIndex.

app/Livewire/Front/Quizzes/Show.php:

class Show extends Component
{
// ...
 
public function changeQuestion(): void
{
$this->currentQuestionIndex++;
 
$this->currentQuestion = $this->questions[$this->currentQuestionIndex];
}
 
// ...
}

Now we can go to the next question.

For the Submit button we will add a submit method. But for submitting for now let's just add a dd('submit') and we will implement it later.

resources/views/livewire/front/quizzes/show.blade.php:

// ...
@else
<div class="mt-4">
<x-primary-button wire:click.prevent="submit">Submit</x-primary-button>
</div>
@endif
</div>

app/Livewire/Front/Quizzes/Show.php:

class Show extends Component
{
// ...
public function submit()
{
dd('submit');
}
// ...
}

The last thing for this lesson, using Alpine.js let's add a timer, for how much time a user has to answer a question. The value for how long the user has to answer, we will save in config/quiz.php in seconds.

config/quiz.php:

return [
'secondsPerQuestion' => 60,
];

And now we can add a timer to the frontend. In the Livewire component in the root div element first, we need to add the x-data element and provide secondsLeft as reactive data with the value from the config. Next, we need to use x-init to initialize the timer. In it, we use the setInterval() function to run commands every second. And what do we need to do every second? We check if secondsLeft is more than 1, if so then decrease the timer by 1 second. Else, we reset secondsLeft and using $wire we call the changeQuestion method in the Livewire component. And of course, we show the timer to the user using x-text.

resources/views/livewire/front/quizzes/show.blade.php:

<div
x-data="{ secondsLeft: {{ config('quiz.secondsPerQuestion') }} }"
x-init="setInterval(() => { if (secondsLeft > 1) { secondsLeft--; } else { secondsLeft = {{ config('quiz.secondsPerQuestion') }}; $wire.changeQuestion(); } }, 1000);">
 
<div class="mb-2">
Time left for this question: <span x-text="secondsLeft" class="font-bold"></span> sec.
</div>
// ...

But now we need to reset the timer when the user presses the Next question button. For this we need to change wire:click for the Alpine.js x-on:click and after resetting the timer call changeQuestion using $wire.

resources/views/livewire/front/quizzes/show.blade.php:

<x-secondary-button wire:click="changeQuestion">
<x-secondary-button x-on:click="secondsLeft = {{ config('quiz.secondsPerQuestion') }}; $wire.changeQuestion();">
Next question
</x-secondary-button>

But what if there are no questions and changeQuestion gets called? You will receive an error like below:

no more questions error

To fix it, we just need to add a simple if. If $currentQuestionIndex is greater or equal we just need to call the submit() method.

class Show extends Component
{
// ...
 
public function changeQuestion(): void
{
$this->currentQuestionIndex++;
 
if ($this->currentQuestionIndex >= $this->questionsCount) {
return $this->submit();
}
 
$this->currentQuestion = $this->questions[$this->currentQuestionIndex];
}
 
// ...
}