Rebuild Laravel E-Learning

1

Part 1: Pengenalan & Mengapa Saya Upgrade

2

Part 2: Setup Laravel 12 + Filament

3

Part 3: Desain Skema Database

4

Part 4: Autentikasi & Role User

5

Part 5: Membangun Sistem Ujian

6

Part 6: Fitur Real-Time

7

Part 7: Strategi Testing

8

Part 8: Deployment

Artikel ini tersedia dalam Bahasa Inggris

šŸ‡¬šŸ‡§ Read in English

10 Maret 2026

šŸ‡®šŸ‡© Bahasa Indonesia

Laravel E-Learning Part 5: Membangun Sistem Ujian

Membuat sistem ujian lengkap dengan Filament resources untuk mengelola ujian, soal, dan implementasi pengalaman mengerjakan ujian untuk siswa.

6 min read


Apa yang Akan Kita Bangun

Di part ini, kita akan membuat:

  1. Exam Resource - Untuk guru membuat dan mengelola ujian
  2. Question Resource - Nested di dalam ujian untuk manajemen soal
  3. Halaman Mengerjakan Ujian - Untuk siswa mengerjakan ujian
  4. Tampilan Hasil - Menampilkan hasil ujian ke siswa

Step 1: Buat Exam Resource

php artisan make:filament-resource Exam --generate

Update app/Filament/Resources/ExamResource.php:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\ExamResource\Pages;
use App\Filament\Resources\ExamResource\RelationManagers;
use App\Models\Exam;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class ExamResource extends Resource
{
    protected static ?string $model = Exam::class;
    protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
    protected static ?string $navigationGroup = 'Manajemen Ujian';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Section::make('Informasi Dasar')
                    ->schema([
                        Forms\Components\TextInput::make('title')
                            ->required()
                            ->maxLength(255),
                        Forms\Components\Select::make('subject_id')
                            ->relationship('subject', 'name')
                            ->required()
                            ->searchable()
                            ->preload(),
                        Forms\Components\Textarea::make('description')
                            ->rows(3),
                    ])->columns(2),

                Forms\Components\Section::make('Jadwal')
                    ->schema([
                        Forms\Components\DateTimePicker::make('start_time')
                            ->required()
                            ->native(false),
                        Forms\Components\DateTimePicker::make('end_time')
                            ->required()
                            ->native(false)
                            ->after('start_time'),
                        Forms\Components\TextInput::make('duration_minutes')
                            ->numeric()
                            ->default(60)
                            ->suffix('menit')
                            ->required(),
                    ])->columns(3),

                Forms\Components\Section::make('Pengaturan')
                    ->schema([
                        Forms\Components\TextInput::make('passing_score')
                            ->numeric()
                            ->default(60)
                            ->suffix('%')
                            ->required(),
                        Forms\Components\Select::make('status')
                            ->options([
                                'draft' => 'Draft',
                                'published' => 'Published',
                                'closed' => 'Closed',
                            ])
                            ->default('draft')
                            ->required(),
                        Forms\Components\Toggle::make('shuffle_questions')
                            ->default(false),
                        Forms\Components\Toggle::make('show_result')
                            ->default(true),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('title')
                    ->searchable()
                    ->sortable(),
                Tables\Columns\TextColumn::make('subject.name')
                    ->sortable(),
                Tables\Columns\TextColumn::make('questions_count')
                    ->counts('questions')
                    ->label('Soal'),
                Tables\Columns\TextColumn::make('duration_minutes')
                    ->suffix(' menit'),
                Tables\Columns\BadgeColumn::make('status')
                    ->colors([
                        'gray' => 'draft',
                        'success' => 'published',
                        'danger' => 'closed',
                    ]),
                Tables\Columns\TextColumn::make('start_time')
                    ->dateTime()
                    ->sortable(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options([
                        'draft' => 'Draft',
                        'published' => 'Published',
                        'closed' => 'Closed',
                    ]),
                Tables\Filters\SelectFilter::make('subject')
                    ->relationship('subject', 'name'),
            ])
            ->actions([
                Tables\Actions\Action::make('manage_questions')
                    ->label('Soal')
                    ->icon('heroicon-o-question-mark-circle')
                    ->url(fn (Exam $record): string => 
                        ExamResource::getUrl('questions', ['record' => $record])),
                Tables\Actions\EditAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\DeleteBulkAction::make(),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            RelationManagers\QuestionsRelationManager::class,
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListExams::route('/'),
            'create' => Pages\CreateExam::route('/create'),
            'edit' => Pages\EditExam::route('/{record}/edit'),
            'questions' => Pages\ManageQuestions::route('/{record}/questions'),
        ];
    }
}

Step 2: Buat Questions Relation Manager

php artisan make:filament-relation-manager ExamResource questions question_text
<?php

namespace App\Filament\Resources\ExamResource\RelationManagers;

use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;

class QuestionsRelationManager extends RelationManager
{
    protected static string $relationship = 'questions';

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Textarea::make('question_text')
                    ->required()
                    ->rows(3)
                    ->columnSpanFull(),
                    
                Forms\Components\Select::make('question_type')
                    ->options([
                        'multiple_choice' => 'Pilihan Ganda',
                        'true_false' => 'Benar/Salah',
                        'essay' => 'Essay',
                    ])
                    ->default('multiple_choice')
                    ->required()
                    ->reactive(),

                Forms\Components\Repeater::make('options')
                    ->schema([
                        Forms\Components\TextInput::make('option')
                            ->required(),
                    ])
                    ->columns(1)
                    ->defaultItems(4)
                    ->visible(fn ($get) => $get('question_type') === 'multiple_choice'),

                Forms\Components\TextInput::make('correct_answer')
                    ->required()
                    ->helperText('Untuk pilihan ganda, masukkan jawaban yang benar persis seperti yang ditulis di atas'),

                Forms\Components\TextInput::make('points')
                    ->numeric()
                    ->default(1)
                    ->required(),

                Forms\Components\TextInput::make('order')
                    ->numeric()
                    ->default(0),
            ]);
    }

    public function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('order')
                    ->sortable(),
                Tables\Columns\TextColumn::make('question_text')
                    ->limit(50)
                    ->searchable(),
                Tables\Columns\BadgeColumn::make('question_type'),
                Tables\Columns\TextColumn::make('points'),
            ])
            ->defaultSort('order')
            ->reorderable('order')
            ->actions([
                Tables\Actions\EditAction::make(),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\DeleteBulkAction::make(),
            ]);
    }
}

Step 3: Buat Halaman Ujian Siswa

Buat halaman custom untuk siswa mengerjakan ujian:

php artisan make:filament-page TakeExam --panel=student
<?php

namespace App\Filament\Student\Pages;

use App\Models\Answer;
use App\Models\Exam;
use App\Models\ExamAttempt;
use App\Models\Question;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;

class TakeExam extends Page implements HasForms
{
    use InteractsWithForms;

    protected static ?string $navigationIcon = 'heroicon-o-document-text';
    protected static string $view = 'filament.student.pages.take-exam';
    protected static bool $shouldRegisterNavigation = false;

    public Exam $exam;
    public ExamAttempt $attempt;
    public array $answers = [];
    public int $currentQuestion = 0;
    public int $timeRemaining = 0;

    public function mount(Exam $exam): void
    {
        $this->exam = $exam;
        
        // Cek apakah ujian aktif
        if (!$exam->isActive()) {
            Notification::make()
                ->title('Ujian tidak tersedia')
                ->danger()
                ->send();
            $this->redirect('/student');
            return;
        }

        // Dapatkan atau buat attempt
        $this->attempt = ExamAttempt::firstOrCreate(
            [
                'exam_id' => $exam->id,
                'student_id' => Auth::id(),
            ],
            [
                'started_at' => now(),
                'status' => 'in_progress',
            ]
        );

        if ($this->attempt->status === 'completed') {
            $this->redirect(route('filament.student.pages.exam-result', ['attempt' => $this->attempt]));
            return;
        }

        // Hitung sisa waktu
        $elapsedMinutes = now()->diffInMinutes($this->attempt->started_at);
        $this->timeRemaining = max(0, ($exam->duration_minutes - $elapsedMinutes) * 60);

        // Muat jawaban yang sudah ada
        $existingAnswers = $this->attempt->answers()->pluck('answer', 'question_id')->toArray();
        $this->answers = $existingAnswers;
    }

    public function getQuestions()
    {
        $questions = $this->exam->questions()->orderBy('order')->get();
        
        if ($this->exam->shuffle_questions) {
            $questions = $questions->shuffle();
        }
        
        return $questions;
    }

    public function saveAnswer(int $questionId, string $answer): void
    {
        Answer::updateOrCreate(
            [
                'exam_attempt_id' => $this->attempt->id,
                'question_id' => $questionId,
            ],
            [
                'answer' => $answer,
            ]
        );

        $this->answers[$questionId] = $answer;
    }

    public function submitExam(): void
    {
        $questions = $this->exam->questions;
        $correctCount = 0;
        $totalPoints = 0;
        $earnedPoints = 0;

        foreach ($questions as $question) {
            $totalPoints += $question->points;
            $userAnswer = $this->answers[$question->id] ?? null;
            
            $isCorrect = strtolower(trim($userAnswer ?? '')) === strtolower(trim($question->correct_answer));
            
            Answer::updateOrCreate(
                [
                    'exam_attempt_id' => $this->attempt->id,
                    'question_id' => $question->id,
                ],
                [
                    'answer' => $userAnswer,
                    'is_correct' => $isCorrect,
                    'points_earned' => $isCorrect ? $question->points : 0,
                ]
            );

            if ($isCorrect) {
                $correctCount++;
                $earnedPoints += $question->points;
            }
        }

        $score = $totalPoints > 0 ? round(($earnedPoints / $totalPoints) * 100) : 0;

        $this->attempt->update([
            'completed_at' => now(),
            'status' => 'completed',
            'score' => $score,
            'correct_answers' => $correctCount,
            'total_answered' => count($this->answers),
        ]);

        Notification::make()
            ->title('Ujian Telah Dikumpulkan!')
            ->body("Skor kamu: {$score}%")
            ->success()
            ->send();

        $this->redirect(route('filament.student.pages.exam-result', ['attempt' => $this->attempt]));
    }
}

Step 4: Buat Halaman Hasil Ujian

<?php

namespace App\Filament\Student\Pages;

use App\Models\ExamAttempt;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;

class ExamResult extends Page
{
    protected static ?string $navigationIcon = 'heroicon-o-chart-bar';
    protected static string $view = 'filament.student.pages.exam-result';
    protected static bool $shouldRegisterNavigation = false;

    public ExamAttempt $attempt;

    public function mount(ExamAttempt $attempt): void
    {
        // Verifikasi kepemilikan
        if ($attempt->student_id !== Auth::id()) {
            abort(403);
        }

        $this->attempt = $attempt->load(['exam', 'answers.question']);
    }

    public function getPassedProperty(): bool
    {
        return $this->attempt->score >= $this->attempt->exam->passing_score;
    }

    public function getGradeProperty(): string
    {
        return match (true) {
            $this->attempt->score >= 90 => 'A',
            $this->attempt->score >= 80 => 'B',
            $this->attempt->score >= 70 => 'C',
            $this->attempt->score >= 60 => 'D',
            default => 'F',
        };
    }
}

Step 5: Blade Views

Buat resources/views/filament/student/pages/take-exam.blade.php:

<x-filament-panels::page>
    <div class="space-y-6" x-data="{ timeRemaining: {{ $this->timeRemaining }} }" x-init="
        setInterval(() => {
            if (timeRemaining > 0) {
                timeRemaining--;
            } else {
                $wire.submitExam();
            }
        }, 1000)
    ">
        {{-- Timer --}}
        <div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg text-center">
            <span class="text-2xl font-bold" x-text="Math.floor(timeRemaining / 60) + ':' + String(timeRemaining % 60).padStart(2, '0')"></span>
            <span class="text-sm text-gray-500 ml-2">tersisa</span>
        </div>

        {{-- Soal-soal --}}
        @foreach($this->getQuestions() as $index => $question)
            <div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
                <h3 class="font-semibold mb-4">
                    Soal {{ $index + 1 }}: {{ $question->question_text }}
                </h3>

                @if($question->question_type === 'multiple_choice')
                    <div class="space-y-2">
                        @foreach($question->options as $option)
                            <label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
                                <input 
                                    type="radio" 
                                    name="question_{{ $question->id }}"
                                    value="{{ $option['option'] }}"
                                    wire:click="saveAnswer({{ $question->id }}, '{{ $option['option'] }}')"
                                    @checked(($answers[$question->id] ?? '') === $option['option'])
                                    class="mr-3"
                                >
                                {{ $option['option'] }}
                            </label>
                        @endforeach
                    </div>
                @elseif($question->question_type === 'true_false')
                    <div class="flex gap-4">
                        <label class="flex items-center">
                            <input type="radio" name="question_{{ $question->id }}" value="true" 
                                wire:click="saveAnswer({{ $question->id }}, 'true')"
                                @checked(($answers[$question->id] ?? '') === 'true')>
                            <span class="ml-2">Benar</span>
                        </label>
                        <label class="flex items-center">
                            <input type="radio" name="question_{{ $question->id }}" value="false"
                                wire:click="saveAnswer({{ $question->id }}, 'false')"
                                @checked(($answers[$question->id] ?? '') === 'false')>
                            <span class="ml-2">Salah</span>
                        </label>
                    </div>
                @else
                    <textarea 
                        wire:blur="saveAnswer({{ $question->id }}, $event.target.value)"
                        class="w-full border rounded-lg p-3"
                        rows="4"
                    >{{ $answers[$question->id] ?? '' }}</textarea>
                @endif
            </div>
        @endforeach

        {{-- Tombol Submit --}}
        <div class="flex justify-center">
            <x-filament::button 
                wire:click="submitExam" 
                wire:confirm="Apakah kamu yakin ingin mengumpulkan? Kamu tidak bisa mengubah jawaban setelah dikumpulkan."
                size="lg"
            >
                Kumpulkan Ujian
            </x-filament::button>
        </div>
    </div>
</x-filament-panels::page>

Ringkasan

Kita telah membangun sistem ujian lengkap:

  • āœ… CRUD Ujian dengan Filament resources
  • āœ… Manajemen soal dengan berbagai tipe
  • āœ… Interface mengerjakan ujian untuk siswa
  • āœ… Auto-save jawaban
  • āœ… Timer countdown dengan auto-submit
  • āœ… Penilaian otomatis
  • āœ… Tampilan hasil

Selanjutnya

Di Part 6, kita akan menambahkan fitur real-time:

  • Monitoring ujian langsung untuk guru
  • Update jawaban real-time
  • Notifikasi dengan Laravel Reverb

Lanjut ke Part 6: Fitur Real-Time →

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Laravel E-Learning Part 4: Authentication & User Roles

Artikel Selanjutnya →

Laravel E-Learning Part 6: Real-Time Features with Livewire

Next article thumbnail