Loading
Please wait...

Laravel E-Learning Rebuild

1

Part 1: Introduction & Why I'm Upgrading

2

Part 2: Setting Up Laravel 11 + Filament

3

Part 3: Database Schema Design

4

Part 4: Authentication & User Roles

5

Part 5: Building the Exam System

6

Part 6: Real-Time Features

7

Part 7: Testing Strategy

8

Part 8: Deployment

7 Januari 2026

Laravel E-Learning Part 5: Building the Exam System

Creating a complete exam system with Filament resources for managing exams, questions, and implementing the student exam-taking experience.

6 min read


What We're Building

In this part, we'll create:

  1. Exam Resource - For teachers to create and manage exams
  2. Question Resource - Nested within exams for question management
  3. Take Exam Page - For students to take exams
  4. Result Display - Show exam results to students

Step 1: Create 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 = 'Exam Management';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Section::make('Basic Information')
                    ->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('Schedule')
                    ->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('minutes')
                            ->required(),
                    ])->columns(3),

                Forms\Components\Section::make('Settings')
                    ->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('Questions'),
                Tables\Columns\TextColumn::make('duration_minutes')
                    ->suffix(' min'),
                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('Questions')
                    ->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: Create 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' => 'Multiple Choice',
                        'true_false' => 'True/False',
                        '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('For multiple choice, enter the correct option exactly as written above'),

                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: Create Student Exam Page

Create a custom page for students to take exams:

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;
        
        // Check if exam is active
        if (!$exam->isActive()) {
            Notification::make()
                ->title('Exam is not available')
                ->danger()
                ->send();
            $this->redirect('/student');
            return;
        }

        // Get or create 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;
        }

        // Calculate remaining time
        $elapsedMinutes = now()->diffInMinutes($this->attempt->started_at);
        $this->timeRemaining = max(0, ($exam->duration_minutes - $elapsedMinutes) * 60);

        // Load existing answers
        $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('Exam Submitted!')
            ->body("Your score: {$score}%")
            ->success()
            ->send();

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

Step 4: Create Exam Result Page

<?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
    {
        // Verify ownership
        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

Create 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">remaining</span>
        </div>

        {{-- Questions --}}
        @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">
                    Question {{ $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">True</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">False</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

        {{-- Submit Button --}}
        <div class="flex justify-center">
            <x-filament::button 
                wire:click="submitExam" 
                wire:confirm="Are you sure you want to submit? You cannot change your answers after submission."
                size="lg"
            >
                Submit Exam
            </x-filament::button>
        </div>
    </div>
</x-filament-panels::page>

Summary

We've built a complete exam system:

  • ✅ Exam CRUD with Filament resources
  • ✅ Question management with multiple types
  • ✅ Student exam-taking interface
  • ✅ Auto-save answers
  • ✅ Countdown timer with auto-submit
  • ✅ Automatic grading
  • ✅ Result display

What's Next

In Part 6, we'll add real-time features:

  • Live exam monitoring for teachers
  • Real-time answer updates
  • Notifications with Laravel Reverb

Continue to Part 6: Real-Time Features →

Continue Reading

Previous article

← Previous Article

Laravel E-Learning Part 4: Authentication & User Roles

Next Article →

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

Next article