In this tutorial, we will make a Quiz CRUD, and a bit later we will add questions to a specific quiz.
The components for Quiz List and Quiz Form will be very similar to the ones we created earlier for Questions List and Form.
We'll begin with the Model and Migration.
php artisan make:model Quiz -m
database/migrations/xxxx_create_quizzes_table.php:
return new class extends Migration{ public function up(): void { Schema::create('quizzes', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('slug')->nullable(); $table->longText('description')->nullable(); $table->boolean('published')->default(0)->nullable(); $table->boolean('public')->default(0)->nullable(); $table->timestamps(); $table->softDeletes(); }); }};
app/Models/Quiz.php:
class Quiz extends Model{ use HasFactory; use SoftDeletes; protected $fillable = [ 'title', 'slug', 'description', 'published', 'public', ]; protected $casts = [ 'published' => 'boolean', 'public' => 'boolean', ];}
Next, let's make a list of quizzes.
php artisan make:livewire Quiz/QuizList
The routes:
routes/web.php:
use App\Http\Livewire\QuizList; 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'); Route::get('quizzes', QuizList::class)->name('quizzes'); });});
Link in the navigation below questions:
resources/views/layouts/navigation.blade.php:
// ...<x-slot name="content"> <x-dropdown-link :href="route('questions')"> Questions </x-dropdown-link> <x-dropdown-link :href="route('quizzes')"> {{ [tl! add:start] }} Quizzes </x-dropdown-link> {{ [tl! add:end] }}</x-slot>// ...
The component:
app/Livewire/Quiz/QuizList.php:
use App\Models\Quiz;use Illuminate\Contracts\View\View; class QuizList extends Component{ public function render(): View { $quizzes = Quiz::latest()->paginate(); return view('livewire.quiz.index', compact('quizzes')); } public function delete(Quiz $quiz): void { abort_if(! auth()->user()->is_admin, Response::HTTP_FORBIDDEN, '403 Forbidden'); $quiz->delete(); }}
And the blade file:
resources/views/livewire/quiz/index.blade.php:
<div> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> Quizzes </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('quiz.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 Quiz </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">Title</span> </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">Slug</span> </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">Description</span> </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">Published</span> </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">Public</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($quizzes as $quiz) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $quiz->id }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $quiz->title }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $quiz->slug }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $quiz->description }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <input class="disabled:opacity-50 disabled:cursor-not-allowed" type="checkbox" disabled @checked($quiz->published)> </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <input class="disabled:opacity-50 disabled:cursor-not-allowed" type="checkbox" disabled @checked($quiz->public)> </td> <td> <a href="{{ route('quiz.edit', $quiz->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({{ $quiz->id }})" 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="8" class="px-6 py-4 text-center leading-5 text-gray-900 whitespace-no-wrap"> No quizzes were found. </td> </tr> @endforelse </tbody> </table> </div> {{ $quizzes->links() }} </div> </div> </div> </div></div>
Now we have a list of quizzes.
Next, the form.
php artisan make:livewire Quiz/QuizForm
Routes for the form:
routes/web.php:
use App\Http\Livewire\Quiz\QuizForm; 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'); Route::get('quizzes', QuizIndex::class)->name('quizzes'); Route::get('quizzes/create', QuizForm::class)->name('quiz.create'); Route::get('quizzes/{quiz}', QuizForm::class)->name('quiz.edit'); });});
The components for the form:
app/Livewire/Quiz/QuizForm.php:
use App\Models\Quiz;use Livewire\Component;use App\Models\Question;use Illuminate\Support\Str;use Illuminate\Contracts\View\View;use Illuminate\Http\RedirectResponse;use Livewire\Features\SupportRedirects\Redirector; class QuizForm extends Component{ public ?Quiz $quiz = null; public string $title = ''; public string $slug = ''; public string|null $description = ''; public bool $published = false; public bool $public = false; public bool $editing = false; public array $questions = []; public array $listsForFields = []; public function mount(Quiz $quiz): void { if ($quiz->exists) { $this->quiz = $quiz; $this->editing = true; $this->title = $quiz->title; $this->slug = $quiz->slug; $this->description = $quiz->description; $this->published = $quiz->published; $this->public = $quiz->public; } else { $this->published = false; $this->public = false; } } public function updatedTitle(): void { $this->slug = Str::slug($this->title); } public function save(): Redirector|RedirectResponse { $this->validate(); if (empty($this->quiz)) { $this->quiz = Quiz::create($this->only(['title', 'slug', 'description', 'published', 'public'])); } else { $this->quiz->update($this->only(['title', 'slug', 'description', 'published', 'public'])); } return to_route('quizzes'); } public function render(): View { return view('livewire.quiz.quiz-form'); } protected function rules(): array { return [ 'title' => [ 'string', 'required', ], 'slug' => [ 'string', 'nullable', ], 'description' => [ 'string', 'nullable', ], 'published' => [ 'boolean', ], 'public' => [ 'boolean', ], 'questions' => [ 'array' ], ]; }}
As you can see the component for the form is almost identical to the one that we made for Questions CRUD. The main difference we use the lifecycle hook to create SLUG after the title is entered, that's what the updatedQuizTitle()
method does. And in the mount()
if Quiz doesn't exist we set published
and public
to false by default because we need them as boolean.
And now the blade file for the form:
resources/views/livewire/quiz/form.blade.php:
<div> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ $editing ? 'Edit Quiz' : 'Create Quiz' }} </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="title" value="Title" /> <x-text-input wire:model.lazy="title" id="title" class="block mt-1 w-full" type="text" name="title" required /> <x-input-error :messages="$errors->get('title')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="slug" value="Slug" /> <x-text-input wire:model.lazy="slug" id="slug" class="block mt-1 w-full" type="text" name="slug" /> <x-input-error :messages="$errors->get('slug')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="description" value="Description" /> <x-textarea wire:model.defer="description" id="description" class="block mt-1 w-full" type="text" name="description" /> <x-input-error :messages="$errors->get('description')" class="mt-2" /> </div> <div class="mt-4"> <div class="flex items-center"> <x-input-label for="published" value="Published"/> <input type="checkbox" id="published" class="mr-1 ml-2" wire:model.defer="published"> </div> <x-input-error :messages="$errors->get('published')" class="mt-2" /> </div> <div class="mt-4"> <div class="flex items-center"> <x-input-label for="public" value="Public"/> <input type="checkbox" id="public" class="mr-1 ml-2" wire:model.defer="public"> </div> <x-input-error :messages="$errors->get('public')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></div>
Now we have a working Quiz CRUD.