Courses

Creating a Quiz System with Laravel 10 + Livewire 3: Step-by-Step

Admin: Questions CRUD

We will start our quiz admin panel by creating questions CRUD, and later we will assign those questions to the quizzes.

create question form

Let's start with a Model with Migration.

php artisan make:model Question -m

database/migrations/xxxx_create_questions_table.php:

return new class extends Migration {
public function up(): void
{
Schema::create('questions', function (Blueprint $table) {
$table->id();
$table->text('question_text');
$table->text('code_snippet')->nullable();
$table->text('answer_explanation')->nullable();
$table->string('more_info_link')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
};

app/Models/Question.php:

class Question extends Model
{
use SoftDeletes;
 
protected $fillable = [
'question_text',
'code_snippet',
'answer_explanation',
'more_info_link',
];
}

Next, the Livewire component for showing questions.

php artisan make:livewire Questions/QuestionList

Remember when we created a Middleware earlier? So now we can use it in the routes.

routes/web.php:

use App\Http\Livewire\Questions\QuestionList;
 
// ...
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
 
Route::middleware('isAdmin')->group(function () {
Route::get('questions', QuestionList::class)->name('questions');
});
});
// ...

Of course, we need the navigation link.

resources/views/layouts/navigation.blade.php:

// ...
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ml-6">
@admin
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>Admin</div>
 
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
 
<x-slot name="content">
<x-dropdown-link :href="route('questions')">
Questions
</x-dropdown-link>
</x-slot>
</x-dropdown>
@endadmin
<x-dropdown align="right" width="48">
<x-slot name="trigger">
// ...

questions navigation

Now for the component itself and the Blade file.

app/Livewire/Questions/QuestionList.php:

use App\Models\Question;
use Illuminate\Contracts\View\View;
 
class QuestionList extends Component
{
public function render(): View
{
$questions = Question::latest()->paginate();
 
return view('livewire.questions.question-list', compact('questions'));
}
 
public function delete(Question $question): void
{
abort_if(! auth()->user()->is_admin, Response::HTTP_FORBIDDEN, '403 Forbidden');
 
$question->delete();
}
}

resources/views/livewire/questions/question-list.blade.php:

<div>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
Questions
</h2>
</x-slot>
 
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<div class="mb-4">
<a href="{{ route('questions.create') }}"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white hover:bg-gray-700">
Create Question
</a>
</div>
 
<div class="mb-4 min-w-full overflow-hidden overflow-x-auto align-middle sm:rounded-md">
<table class="min-w-full border divide-y divide-gray-200">
<thead>
<tr>
<th class="w-16 bg-gray-50 px-6 py-3 text-left">
</th>
<th class="bg-gray-50 px-6 py-3 text-left">
<span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Question text</span>
</th>
<th class="w-40 bg-gray-50 px-6 py-3 text-left">
</th>
</tr>
</thead>
 
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
@forelse($questions as $question)
<tr class="bg-white">
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
{{ $question->id }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
{{ $question->question_text }}
</td>
<td>
<a href="{{ route('questions.edit', $question->id) }}" class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white hover:bg-gray-700">
Edit
</a>
<button wire:click="delete({{ $question }})" class="rounded-md border border-transparent bg-red-200 px-4 py-2 text-xs uppercase text-red-500 hover:bg-red-300 hover:text-red-700">
Delete
</button>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-6 py-4 text-center leading-5 text-gray-900 whitespace-no-wrap">
No questions were found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
 
{{ $questions->links() }}
</div>
</div>
</div>
</div>
</div>

Here we do nothing special, just getting all the questions and showing them on the table. Also, we added the delete() method to which we pass the Question model, and in it, we just delete the question. The empty questions list should be like below:

questions list

Next, the form for creating and editing the question. First, the component and adding into a route.

php artisan make:livewire Questions/QuestionForm

routes/web.php:

use App\Http\Livewire\Questions\QuestionForm;
use App\Http\Livewire\Questions\QuestionList;
 
// ...
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
 
Route::middleware('isAdmin')->group(function () {
Route::get('questions', QuestionIndex::class)->name('questions');
Route::get('questions/create', QuestionForm::class)->name('questions.create');
Route::get('questions/{question}', QuestionForm::class)->name('questions.edit');
});
});
// ...

The component itself:

app/Livewire/Questions/QuestionForm.php:

use App\Models\Question;
use Illuminate\Http\RedirectResponse;
use Livewire\Features\SupportRedirects\Redirector;
 
class QuestionForm extends Component
{
public ?Question $question = null;
 
public string $question_text = '';
public string|null $code_snippet = '';
public string|null $answer_explanation = '';
public string|null $more_info_link = '';
 
public bool $editing = false;
 
public function mount(Question $question): void
{
if ($question->exists) {
$this->question = $question;
$this->editing = true;
$this->question_text = $question->question_text;
$this->code_snippet = $question->code_snippet;
$this->answer_explanation = $question->answer_explanation;
$this->more_info_link = $question->more_info_link;
}
}
 
public function save(): Redirector|RedirectResponse
{
$this->validate();
 
if (empty($this->question)) {
$this->question = Question::create($this->only(['question_text', 'code_snippet', 'answer_explanation', 'more_info_link']));
} else {
$this->question->update($this->only(['question_text', 'code_snippet', 'answer_explanation', 'more_info_link']));
}
 
return to_route('questions');
}
 
public function render(): View
{
return view('livewire.questions.question-form');
}
 
protected function rules(): array
{
return [
'question_text' => [
'string',
'required',
],
'code_snippet' => [
'string',
'nullable',
],
'answer_explanation' => [
'string',
'nullable',
],
'more_info_link' => [
'url',
'nullable',
],
];
}
}

Here we also aren't doing anything special. The only thing, we check in the mount() if the Model exists in the DB, and if it does, we just set $editing to true. This way in the form we show if it's for creating or editing.

Throughout this course, we will use the textarea field. For this, we will create a blade component to easily reuse it.

php artisan make:component Textarea --view

resources/view/components/textarea.blade.php:

<textarea {!! $attributes->merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) !!}>{{ $slot }}</textarea>

And the form for the questions:

resources/views/livewire/questions/form.blade.php:

<div>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $editing ? 'Edit Question' : 'Create Question' }}
</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">
<form wire:submit="save">
<div>
<x-input-label for="question_text" value="Question text" />
<x-textarea wire:model="question_text" id="question_text" class="block mt-1 w-full" type="text" name="question_text" required />
<x-input-error :messages="$errors->get('question_text')" class="mt-2" />
</div>
 
<div class="mt-4">
<x-input-label for="code_snippet" value="Code snippet" />
<x-textarea wire:model="code_snippet" id="code_snippet" class="block mt-1 w-full" type="text" name="code_snippet" />
<x-input-error :messages="$errors->get('code_snippet')" class="mt-2" />
</div>
 
<div class="mt-4">
<x-input-label for="answer_explanation" value="Answer explanation" />
<x-textarea wire:model="answer_explanation" id="answer_explanation" class="block mt-1 w-full" type="text" name="answer_explanation" />
<x-input-error :messages="$errors->get('answer_explanation')" class="mt-2" />
</div>
 
<div class="mt-4">
<x-input-label for="more_info_link" value="More info link" />
<x-text-input wire:model="more_info_link" id="more_info_link" class="block mt-1 w-full" type="text" name="more_info_link" />
<x-input-error :messages="$errors->get('more_info_link')" class="mt-2" />
</div>
 
<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>

create question form

avatar

Code in resources/views/layouts/navigation.blade.php has some little mistake

{{ [tl! add:start] }} {{ [tl! add:end] }}

Those two fragments after @admin and @endadmin should be removed.

avatar

what is [tl! add:start]

avatar

Not sure, but it will throw exception. I checked github source code for this course, and it doesn't contain such thing.

avatar

Yes, sorry, it's for the code highlighter on the page, will fix.

avatar

Hello, php artisan make:component Textarea --view -> The "--view" option does not exist.


There is an option in the help option named --inline --inline Create a component that renders an inline view I am working with bootstrap, i am affraid to make conflict if i install tailwind css/js now. I don't know alpines.js too well, so my question is whether this is still possible?

avatar

As I see, the --view option exists, it's in the docs. Maybe you're using some older Laravel version?

avatar

Hello, i ve just seen you answer. I am trying to do it today for the second time. Before that i work on you excellent course about practical livewire 😉. At this moment, i can cross the CRUD part (04). It's hard to admit 😥 I am working on local server Ubuntu 20.04 with laravel 8.83.27 and php 8.2.8 and livewire 2.12. I use : protected $paginationTheme = 'bootstrap'; because most of my big enough website using it. Alpine JS is declared. I never used Laravel component until now except livewire component many times. Can you confirme me that i can continued like this or not because my config not conformed. What about the risk to update laravel from 8 to 9. Iv got many thinks done on it and my students start school in 3 weeks..🤕😵🥴 Thanks Damien from France

avatar

I found a solution for the question-list with : "->layout('layouts.layouthtmlTailwindQuestionL'); ". Trying hard to do the same for the question-form. Maybe it could be easyer this next step. 😵🥴

avatar

i finaly ended the part 02 🤕 1 - add function view()->layout(2 layout's type with Tailwind) 2 - redirect()->route() instead return to_route() 3 - delete :view | :redirector 4 - protected $rules validates against function rules(): array


My finally question is : does update laravel from 8 to 9 is a really good idea ? (i work on forge & digital ocean) Thank you

avatar

Generally, it's a good idea to always use the latest version, so Laravel 10 is the latest. But if you don't specifically NEED to update for some Laravel 9/10 features, then no pressure of doing that, if your code works with Laravel 8.

avatar

app/Models/Question.php:

My default laravel 10 is

avatar

I confirm that I set up this quiz from laravel 8 (Ubuntu 20.04 -Mysql 8). I encountered certain problems specific to my configuration but I still managed to finalize this quiz which now works perfectly. My problems were mainly related to the fact that I'm using boostrap and not tailwind. In addition,** I had to adapt the form rules here and there** and I had to manage (in my head) the case where the user does not make a choice making hydration in database impossible. My students use it almost every day now. Thanks again to Mr. Povilas

avatar

Following my previous message, this has just come back to me. In /Livewire/front/quizzes/show/ at the "TestAnswer" method. When the option is null, this function "'option_id' => $option ?? null," did not work. I had to recreate a new method: if(empty($option)) { $status = 1; $result; TestAnswer::create([ 'user_id' => auth()->id(), 'test_id' => $test->id, 'question_id' => $this->questions[$key]->id, 'option_id' => null, 'correct' => $status, ]); $test->update([ 'result' => $result, ]); return redirect()->route('results.show', $test); }; TestAnswer::create([ 'user_id' => auth()->id(), 'test_id' => $test->id, 'question_id' => $this->questions[$key]->id, 'option_id' => $option ?? null, 'correct' => $status, ]); } } 👍 Damien Burdo from France